Flutter 基础体系 · 第 69/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 集成测试:设备、权限、网络、性能和失败证据

Flutter 集成测试(integration test)验证的是“编译后的应用在目标平台上运行时,多个真实组件能否共同完成一条用户路径”。它通常会同时经过 Flutter framework、插件、平台通道、操作系统权限、网络栈和渲染管线,因此与单元测试、Widget 测试的故障边界不同。

一个登录流程可以形式化为:

输入Widget 状态变化Dart 业务逻辑HTTP 或平台通道操作系统返回结果渲染\text{输入} \rightarrow \text{Widget 状态变化} \rightarrow \text{Dart 业务逻辑} \rightarrow \text{HTTP 或平台通道} \rightarrow \text{操作系统} \rightarrow \text{返回结果} \rightarrow \text{渲染}

集成测试的价值不在于重复验证每个函数,而在于验证这条跨层路径在指定设备、权限和环境下成立。代价是运行更慢、依赖更多、失败原因更复杂,所以测试必须同时设计执行环境和失败证据。


1. 集成测试到底测试什么

Flutter 测试通常分为三层:

类型 运行位置 主要验证内容
单元测试 Dart VM 函数、算法、状态转换
Widget 测试 Flutter 测试环境 Widget 树、交互、布局和状态
集成测试 模拟器、真机、桌面或浏览器 编译后的应用与平台、插件、网络、渲染的协作

Widget 测试中的 WidgetTester 可以构造一个隔离的 Flutter 环境,但它不等价于 Android 或 iOS 的真实窗口,也不会自动替代系统权限弹窗。集成测试仍然使用 WidgetTester 风格的 API,但绑定的是 IntegrationTestWidgetsFlutterBinding,测试进程会连接正在运行的应用。

一个最小测试如下:

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:example/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('用户可以打开设置页', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    await tester.tap(find.byKey(const Key('settings-button')));
    await tester.pumpAndSettle();

    expect(find.text('设置'), findsOneWidget);
  });
}

这里有三个关键点:

  1. ensureInitialized() 必须在测试开始时调用,使测试绑定具备截图、性能追踪和平台通信能力。
  2. app.main() 启动被测应用。它不是重新启动测试进程,而是在当前测试环境中构造应用。
  3. pumpAndSettle() 会反复处理帧,直到没有待处理异步任务或动画。它适合页面跳转,但如果应用有无限动画、持续轮询或未结束的流,测试可能一直等待。

如果生产入口依赖命令行参数、远程配置或初始化顺序,不应在测试里复制另一套启动逻辑。更可靠的方式是给应用入口提供明确的启动配置:

void main() {
  runApp(const MyApp());
}

Future<void> mainForTest() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp(
    configuration: AppConfiguration(
      apiBaseUrl: 'https://test.example.invalid',
    ),
  ));
}

然后测试调用 mainForTest()。测试入口必须和生产入口共享真实的依赖装配过程,只替换明确需要控制的外部依赖。


2. 工程配置和执行命令

pubspec.yaml 中添加测试依赖:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

测试文件通常放在:

integration_test/
  login_test.dart

移动端执行:

flutter test integration_test/login_test.dart -d <device-id>

先用以下命令确认设备 ID:

flutter devices

例如:

flutter test integration_test/login_test.dart -d emulator-5554

命令成功并不只表示 Dart 断言通过。它还意味着:

  • Flutter 能为目标平台编译应用;
  • 应用能够安装或启动;
  • 测试代码能与应用建立通信;
  • 测试进程最终收到成功状态。

如果只执行:

flutter test test/widget_test.dart

不会运行 integration_test/ 下的真实设备测试。反过来,集成测试也不能替代所有 Widget 测试,因为启动设备和构建应用的成本更高。

当前 Flutter 中,integration_test 是 Flutter SDK 提供的测试包。早期项目经常使用 flutter_drivertest_driver/driver.dart,这套写法在存量代码中仍可能存在,但新测试通常应优先使用 integration_test。不要把旧的 flutter drive 流程和新的测试入口混用,除非项目明确需要 Web 或旧流水线兼容。

Android、iOS、桌面和 Web 的执行差异

Android 和 iOS 是移动端集成测试的主要目标:

  • Android 可以使用模拟器或真机;
  • iOS 通常需要 macOS、Xcode 和已配置的模拟器或真机签名;
  • 真机还涉及 USB 调试、开发者模式、签名和网络可达性。

桌面平台的测试可以使用对应设备运行,例如 Windows、macOS 或 Linux,但插件是否实现桌面端、窗口焦点和系统权限模型,必须单独确认。移动端的相机、定位和通知权限不能直接推断为桌面端行为。

Web 测试运行在浏览器中,浏览器权限、跨域策略、Service Worker、渲染和移动端原生权限都不同。浏览器中的通知、摄像头和定位权限由浏览器控制,不等价于 Android Manifest 或 iOS Info.plist。因此,一个测试文件可以共享业务断言,但平台准备、权限处理和网络服务通常要按平台拆分。


3. 设备是测试条件,而不是测试细节

集成测试的结果是条件函数:

R=f(A,D,O,P,N,C)R = f(A, D, O, P, N, C)

其中:

  • AA:应用构建产物;
  • DD:设备或模拟器;
  • OO:操作系统版本;
  • PP:权限和系统状态;
  • NN:网络环境;
  • CC:测试数据和后端配置。

当测试失败时,只记录“测试失败”并不足以定位问题,因为 ff 的输入不完整。例如,同一个定位测试可能在 Android 模拟器中通过、在 iOS 真机中失败,原因可能是权限描述缺失,也可能是设备没有真实定位数据。

因此,设备矩阵至少应记录:

平台:Android
设备:Pixel_7_API_34
系统:Android 14
架构:x86_64
构建模式:debug
后端环境:staging
网络:模拟器默认网络
权限:首次安装,未预授权

测试前后的状态也很重要。一次测试如果依赖“应用首次安装”状态,测试结束后不清理数据,下一次运行就可能绕过引导页或权限弹窗。常见隔离方式包括:

adb shell pm clear com.example.app

该命令会删除 Android 应用数据,通常也会重置应用内登录状态;它不会自动重置所有设备级设置。使用前要确认包名,并注意它会破坏本地调试数据。

iOS 模拟器可以通过 Xcode 或 simctl 管理应用和模拟器状态。不同 Xcode 版本对隐私数据库和设备控制命令的支持可能变化,不应把某个命令当作跨版本规范。CI 中应固定 Xcode、Flutter 和模拟器镜像版本,并在日志中打印实际版本。

设备准备的核心不是“每次都使用全新设备”,而是让测试明确知道自己依赖哪些状态:

  • 应用数据是否为空;
  • 是否已经登录;
  • 是否授予权限;
  • 系统时间和时区是什么;
  • 后端是否存在测试账号和测试数据;
  • 设备是否能访问测试服务。

4. 权限:Manifest、运行时授权和系统状态是三件事

移动端权限至少包含三个层次:

  1. 声明层:应用是否在平台配置中声明权限;
  2. 运行时层:应用是否调用系统 API 请求权限;
  3. 系统状态层:用户是否允许、拒绝、仅本次允许,或在设置中关闭。

只声明权限不等于已经获得权限。只调用请求 API 也不等于一定会出现弹窗,因为系统可能已经记录过用户选择。

Android

例如相机权限通常需要在 android/app/src/main/AndroidManifest.xml 中声明:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />

    <application
        android:label="example"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <!-- ... -->
    </application>
</manifest>

应用还必须在运行时请求权限。项目可以使用插件,也可以通过自有平台通道调用 Android API。以插件为例,具体插件版本和平台支持应以其文档为准:

final status = await Permission.camera.request();

if (status.isGranted) {
  // 继续打开相机
} else if (status.isPermanentlyDenied) {
  // 引导用户进入系统设置
} else {
  // 显示权限不足提示
}

集成测试要验证的不是某个 status 枚举,而是完整行为:

testWidgets('拒绝相机权限时显示说明', (tester) async {
  app.main();
  await tester.pumpAndSettle();

  await tester.tap(find.byKey(const Key('scan-button')));
  await tester.pumpAndSettle();

  expect(find.text('需要相机权限才能扫码'), findsOneWidget);
});

这个测试能否稳定运行,取决于设备当前授权状态。如果设备已经授权,系统弹窗不会出现,测试也无法验证“拒绝”路径。因此,权限测试通常需要:

  • 在测试前撤销权限;
  • 使用测试替身模拟授权结果;
  • 或使用平台工具预置权限状态。

Android 上可以使用类似以下命令撤销相机权限:

adb shell pm revoke com.example.app android.permission.CAMERA

该命令要求应用包名正确,并受 Android 版本、目标 SDK 和权限类型影响。真机和模拟器的行为也可能不同。测试命令成功只说明系统接受了撤销请求,不说明应用一定会按预期处理拒绝分支,仍需运行断言。

iOS

iOS 需要在 ios/Runner/Info.plist 中提供用途说明。例如相机:

<key>NSCameraUsageDescription</key>
<string>用于扫描二维码</string>

缺少必要的用途说明时,应用可能在访问系统能力时直接终止,而不是返回普通的“拒绝”结果。这个故障表现与 Dart 层断言失败不同:测试可能只看到应用退出、连接断开或原生日志中的异常。

iOS 权限状态还受模拟器、真机和系统版本影响。可以通过模拟器设置或 Xcode 管理,也可以在支持的环境中使用 simctl privacy 预置状态,但命令参数和可管理的权限类型应以当前 Xcode 文档为准。不要将“模拟器中能自动授予权限”当作真机保证。

更稳定的权限测试边界

如果每条测试都依赖真实系统弹窗,测试会受到设备历史状态影响。更稳妥的拆分是:

  • Widget 或集成测试中,用可注入的 PermissionService 验证允许、拒绝和永久拒绝后的业务 UI;
  • 少量平台集成测试验证真实权限声明、真实请求和真实系统回调;
  • CI 使用明确的设备重置或权限预置步骤。

例如:

abstract interface class PermissionService {
  Future<bool> requestCamera();
}

class CameraController {
  CameraController(this.permissionService);

  final PermissionService permissionService;

  Future<bool> startScan() async {
    return permissionService.requestCamera();
  }
}

这样做不是绕过平台测试,而是把“业务如何处理权限结果”和“平台是否正确返回权限结果”分开。前者可以稳定大量覆盖,后者保留少量真实设备验证。


5. 网络:可控依赖比“真的访问互联网”更接近可重复测试

网络测试最容易产生误判。一个测试失败可能来自:

  • DNS 解析失败;
  • TLS 证书或系统信任链问题;
  • 后端返回 5xx;
  • 请求超时;
  • 数据已被其他测试修改;
  • 模拟器无法访问宿主机;
  • 应用自身没有正确处理响应。

因此,集成测试中的网络依赖必须先区分两种目标:

5.1 验证应用流程:使用可控服务

如果目标是验证“登录按钮、加载状态、错误提示和页面跳转”,不应让测试依赖公共互联网。可以给应用注入一个本地或测试服务:

abstract interface class AuthApi {
  Future<String> login(String username, String password);
}

class LoginController {
  LoginController(this.api);

  final AuthApi api;

  Future<void> submit(String username, String password) async {
    final token = await api.login(username, password);
    // 保存 token 并更新状态
    debugPrint('logged in: $token');
  }
}

测试环境可以提供固定响应的 FakeAuthApi

class SuccessfulAuthApi implements AuthApi {
  @override
  Future<String> login(String username, String password) async {
    if (username != 'test@example.com' || password != 'correct') {
      throw StateError('invalid credentials');
    }
    return 'test-token';
  }
}

这类替身适合验证 UI 和业务状态,但它不会验证真实 JSON 序列化、HTTP 状态码映射、TLS 或插件网络行为。因此不能用它完全替代真实网络测试。

5.2 验证真实 HTTP 链路:使用专用测试服务

当目标是验证真实 HTTP 客户端、鉴权头、序列化和后端契约时,应使用稳定的测试环境或进程内 HTTP 服务。测试服务需要提供:

  • 固定的测试数据;
  • 独立的测试账号;
  • 可重复的响应;
  • 明确的错误场景;
  • 测试后清理或隔离数据。

访问宿主机服务时,地址不是所有平台都相同:

  • Android 模拟器通常使用 10.0.2.2 访问宿主机;
  • iOS 模拟器通常可以使用宿主机地址,但网络配置仍可能影响结果;
  • 真机必须访问局域网地址或可达的测试环境;
  • Web 受浏览器同源策略和 CORS 影响;
  • 桌面端通常可以访问 localhost,但沙箱或网络策略可能改变行为。

localhost 写死在所有平台上是常见错误:在 Android 模拟器中,它指向模拟器自身,而不是开发机。

网络等待也应有明确超时。没有超时的请求会让 pumpAndSettle() 看起来像挂死:

final response = await client
    .get(uri)
    .timeout(const Duration(seconds: 10));

测试应断言用户可观察状态,而不是只等待请求完成:

expect(find.byKey(const Key('loading-indicator')), findsOneWidget);

await tester.pumpAndSettle();

expect(find.text('网络请求失败,请稍后重试'), findsOneWidget);
expect(find.byKey(const Key('retry-button')), findsOneWidget);

对于错误测试,服务器必须能稳定产生错误,例如固定返回 401、403、500 或延迟响应。不要通过“随机断网”制造测试条件,因为随机故障只能证明环境不稳定,不能证明应用正确处理了某一种错误。


6. 测试中的异步、动画和生命周期

集成测试常见的错误不是业务断言,而是错误地估计了异步完成条件。

pumpAndSettle() 的含义是持续泵送帧,直到 Flutter 测试绑定认为没有更多需要处理的帧。它不是“等待所有网络请求完成”的通用命令。网络请求、原生回调或持续流如果没有导致可观察的帧变化,pumpAndSettle() 也不能替代显式等待。

对于明确的状态变化,可以使用有限轮询:

Future<void> waitForText(
  WidgetTester tester,
  String text, {
  Duration timeout = const Duration(seconds: 10),
}) async {
  final end = DateTime.now().add(timeout);

  while (DateTime.now().isBefore(end)) {
    if (find.text(text).evaluate().isNotEmpty) {
      return;
    }
    await tester.pump(const Duration(milliseconds: 100));
  }

  throw TestFailure('在 $timeout 内没有找到文本:$text');
}

这个辅助函数每 100 毫秒检查一次 Widget 树,并且有最大等待时间。它仍然只适合检查界面状态,不应掩盖真正未完成的网络或平台任务。

应用生命周期也会影响测试。切到后台、系统弹窗出现、权限页面打开或应用被系统回收,可能导致:

resumed -> inactive -> paused -> resumed

不同平台的生命周期回调顺序并不完全一致。测试如果需要验证恢复行为,应通过应用公开的状态和可观察 UI 断言,例如恢复后是否重新加载,而不是假定所有平台都发出相同回调序列。


7. 性能测试:从“感觉卡”变成可比较的证据

集成测试中的性能测试,主要观察真实渲染、布局、合成和滚动路径。Widget 测试中的虚拟环境不能代表真机 GPU、屏幕刷新率和插件开销。

Flutter 一帧是否按时完成,核心约束是:

Tbuild+Tlayout+Tpaint+TrasterTbudgetT_{\text{build}} + T_{\text{layout}} + T_{\text{paint}} + T_{\text{raster}} \leq T_{\text{budget}}

其中:

  • build 是 Widget 构建;
  • layout 是布局计算;
  • paint 是绘制指令生成;
  • raster 是光栅化和 GPU 相关工作;
  • TbudgetT_{\text{budget}} 是一帧可用时间。

在 60 Hz 屏幕上,一帧周期约为:

160s16.67ms\frac{1}{60}\text{s} \approx 16.67\text{ms}

在 120 Hz 屏幕上约为 8.33 ms。超过预算不一定每次都可见,但连续超预算会造成掉帧。这个计算是刷新率的物理约束,不是 Flutter 对所有设备的性能保证。

可以使用集成测试绑定的 traceAction 采集一次操作期间的 Timeline:

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:example/main.dart' as app;

void main() {
  final binding =
      IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('长列表滚动性能', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    final timeline = await binding.traceAction(() async {
      final list = find.byKey(const Key('message-list'));

      await tester.fling(
        list,
        const Offset(0, -1200),
        3000,
      );
      await tester.pumpAndSettle();
    });

    // timeline 可由测试工具或 CI 保存、分析。
    expect(timeline.json.isNotEmpty, isTrue);
  });
}

前置条件是页面必须有稳定的数据量、固定的操作路径和相同的构建模式。第一次运行可能包含字体、Shader 或资源初始化,不能直接与后续运行混为一谈。为了减少噪声,性能测试通常需要:

  1. 预热应用;
  2. 使用固定数据;
  3. 只测一个明确操作;
  4. 在相同设备和电源状态下运行;
  5. 保存 Timeline,而不是只记录“通过”。

性能结果应与设备绑定。一个测试在高端模拟器上通过,不代表低端真机通过;模拟器 GPU 也不等价于物理设备 GPU。CI 的性能门槛应先通过多次基线测量建立,不能凭经验写一个任意毫秒数。

traceAction 产生的是操作期间的时间线证据,适合分析事件发生顺序和耗时分布。它不是完整的实验室性能系统,也不能单独证明内存、耗电、网络吞吐或所有设备上的帧率。需要更深入分析时,应结合 Flutter DevTools 的 Performance 页面、物理设备 profile/release 构建以及平台工具。


8. 失败证据:让一次失败可以被复现和归因

“断言失败”只是结果,不是证据。集成测试失败时,至少要回答:

  • 在哪台设备、哪个系统版本上失败?
  • 测试执行到了哪一步?
  • 当时页面显示了什么?
  • 网络请求发往哪里,返回了什么状态?
  • 应用是否被系统终止?
  • 是 Flutter 断言失败,还是原生进程、设备连接或环境失败?

截图

集成测试绑定提供了截图能力:

testWidgets('登录失败时保留错误信息', (tester) async {
  final binding =
      IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  app.main();
  await tester.pumpAndSettle();

  await tester.tap(find.byKey(const Key('login-button')));
  await tester.pumpAndSettle();

  expect(find.text('账号或密码错误'), findsOneWidget);

  await binding.takeScreenshot('login-error');
});

截图名称应包含稳定语义,例如 login-errorpermission-denied,而不是 image1。测试框架或 CI 是否自动保存截图,取决于运行方式和平台集成;必要时应确认输出目录和构建日志中确实出现了文件。

Android 某些场景需要先把 Flutter surface 转换为可截图图像:

await binding.convertFlutterSurfaceToImage();
await tester.pump();
await binding.takeScreenshot('screen');

该能力具有平台和渲染条件限制,不能假定在 Web、所有桌面后端或每种原生视图组合中都能得到完整截图。应用包含 PlatformView、相机预览或视频表面时,截图可能只包含 Flutter 部分。

日志和请求证据

应用应在测试环境输出结构化日志,至少包含:

test=login_invalid_password
device=emulator-5554
build=debug-abc123
request_id=req-8f2
endpoint=/api/login
status=401
state=LoginFailure

日志中不要写入真实密码、访问令牌或个人数据。网络日志可以记录方法、路径、状态码和请求 ID,但应对 Authorization 头和敏感响应字段脱敏。

如果测试使用真实测试后端,请让客户端把请求 ID 记录到应用日志,并让服务端按请求 ID 查询。这样可以区分:

  • 请求没有离开设备;
  • 请求到达服务端但被拒绝;
  • 服务端成功返回但客户端解析失败;
  • 客户端收到结果但状态机没有更新。

失败时保留最后状态

可以在关键步骤加入语义化断言:

expect(
  find.byKey(const Key('checkout-page')),
  findsOneWidget,
  reason: '提交订单后应进入结算页',
);

当失败时,断言消息能说明业务预期。相比:

expect(find.byType(Container), findsOneWidget);

使用稳定的 Key 或语义标签更能表达失败位置,也减少布局重构造成的无意义失败。

一条完整失败路径可以表示为:

flowchart TD
    A[启动应用] --> B[准备设备与权限]
    B --> C[执行用户操作]
    C --> D{是否到达预期状态}
    D -- 是 --> E[保存性能或业务结果]
    D -- 否 --> F[截图]
    F --> G[保存应用日志]
    G --> H[关联网络请求 ID]
    H --> I[收集设备与构建信息]
    I --> J[上传 CI 工件]

关键路径是先记录“测试执行到了哪里”,再收集 UI、应用、网络和设备证据。只有截图而没有设备版本,无法判断平台差异;只有服务端日志而没有客户端状态,无法判断解析或渲染失败。


9. 常见误区与真实边界

误区一:pumpAndSettle() 可以等待所有事情

它主要等待 Flutter 帧稳定,不是通用的 Future 管理器。持续动画、轮询、未关闭的订阅都可能让它超时。正确做法是让应用暴露明确状态,并对网络、原生回调和动画分别设置等待条件。

误区二:测试通过就代表真实用户通过

测试账号、固定设备和 staging 后端只能证明一个受控条件成立。真实用户还可能遇到不同系统版本、字体缩放、地区、时区、网络代理、权限历史和低端硬件。集成测试应覆盖代表性矩阵,但不能替代生产监控和崩溃报告。

误区三:模拟器中的权限和性能等价于真机

模拟器通常没有真实摄像头、定位传感器、电池和 GPU 行为。模拟器适合快速验证流程,真机适合验证权限细节、硬件插件、性能和网络切换。两者的测试职责必须明确区分。

误区四:把所有外部依赖都替换掉

全部替换后,测试很稳定,但可能漏掉 Android/iOS 插件注册错误、JSON 契约错误、TLS 配置错误和真实导航行为。合理边界是:高频业务路径使用可控替身,少量端到端测试保留真实平台和真实 HTTP 链路。

误区五:只在失败时截图,不保存环境

没有设备型号、系统版本、构建提交、后端环境和权限状态,截图只能显示“当时看到了什么”,不能解释“为什么发生”。CI 应把这些元数据与截图、Timeline、日志作为同一测试工件保存。


10. 一条可交付的集成测试流程

一个可复现的移动端集成测试流程通常如下:

flutter --version
flutter pub get
flutter devices
adb shell pm clear com.example.app
flutter test integration_test/login_test.dart -d emulator-5554

每一步的作用不同:

  1. flutter --version 固定工具链事实,避免“本地能过、CI 不能过”却不知道版本差异。
  2. flutter pub get 确保依赖解析完成。
  3. flutter devices 确认目标设备在线,并记录设备 ID。
  4. pm clear 清理 Android 应用数据,避免登录和权限状态污染。
  5. flutter test 编译、安装、运行测试,并返回进程退出状态。

如果失败,应根据表现分层处理:

  • 编译失败:检查 SDK、插件平台实现和原生配置;
  • 应用启动失败:检查 Manifest、Info.plist、签名和原生崩溃日志;
  • 测试无法连接:检查测试绑定、设备连接和启动入口;
  • 断言失败:检查截图、应用日志和测试数据;
  • 超时:区分网络未返回、动画未结束、平台回调未触发和设备过载;
  • 性能回归:比较相同设备上的 Timeline,而不是跨设备比较单个数字。

集成测试的最终产物不应只有一个退出码。一个有交付价值的结果至少包含:

测试名称
设备和系统版本
Flutter/Dart/构建信息
权限初始状态
后端环境
测试日志
失败截图
关键网络请求证据
性能 Timeline(如适用)

这样,设备、权限、网络、性能和失败证据就不再是测试脚本之外的杂项,而是集成测试输入、执行过程和输出结果的一部分。只有把这些条件显式化,测试通过才具有可解释性,测试失败才具有可恢复性。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。