测试:单元、Widget 与集成测试

测试在 Flutter 里的性价比很高:纯逻辑测试毫秒级跑完,Widget 测试不用真机就能验证交互,只有跨端到端的部分才需要模拟器。真正让人放弃写测试的原因通常只有两个——不知道怎么 mock 依赖,以及不知道 pumppumpAndSettle 该用哪个。本章按测试金字塔自下而上讲,每个层级都给出可直接抄的模板。

测试金字塔

层级依赖的包运行环境覆盖目标数量占比
单元测试testmocktailDart VM,无 UI仓储、格式化、状态机的纯逻辑最多
Widget 测试flutter_test无头引擎,不渲染真像素页面交互、状态变化、异常态中等
集成测试integration_test真机或模拟器关键链路端到端只留最关键的几条

判断标准:一段逻辑如果不需要看界面就能断言对错,它就应该被单元测试覆盖;界面上的“点了按钮会怎样”交给 Widget 测试;只有跨越真实网络、平台插件、路由跳转的链路才值得上集成测试。

单元测试:手写 fake 往往比 mock 好用

// test/cart_repository_test.dart
class FakeCartApi implements CartApi {
  @override
  Future<List<Item>> fetch() async => const [Item('a', 10), Item('b', 20)]; // 固定数据,结果可预测
}

void main() {
  group('CartRepository', () {
    test('汇总金额时忽略已下架商品', () async {
      final repo = CartRepository(api: FakeCartApi());
      expect(await repo.totalPrice(), 30); // 断言精确值,不要只断言不为 null
    });

    test('接口报错时抛出业务异常', () async {
      final repo = CartRepository(api: FailingApi());
      await expectLater(repo.totalPrice(), throwsA(isA<BizException>()));
    });
  });
}

接口只有一两个方法时,手写 fake 比引入 mocktail 更直观;依赖多、需要验证调用次数时再用 mocktailwhen/verify(它不需要生成代码,比 mockito 少一步 build_runner)。

Widget 测试:testWidgets

// test/widget/product_list_test.dart
void main() {
  testWidgets('列表加载完成后展示商品', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: ProductListPage(fake: true)));

    expect(find.byType(CircularProgressIndicator), findsOneWidget); // 首帧应为加载态
    await tester.pumpAndSettle(); // 等 Future 与动画都结束

    expect(find.text('商品 A'), findsOneWidget);
    expect(find.byType(ProductCard), findsNWidgets(2)); // 断言结构而不是只断言文字
  });

  testWidgets('搜索框输入后过滤列表', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: SearchPage()));
    await tester.enterText(find.byKey(const Key('searchField')), '耳机');
    await tester.pump(); // 只有一帧 rebuild,没有动画,用 pump 即可

    expect(find.text('蓝牙耳机'), findsOneWidget);
    expect(find.text('蓝牙音箱'), findsNothing);
  });
}

查找器优先顺序:find.byKey(最稳,不随文案变化)> find.byType > find.text(文案一改测试就碎)。给关键控件加 Key 是最低成本的测试友好改造。

方法行为何时用
pump()触发一帧重建改状态后的即时断言、精确控制帧数
pump(Duration)推进指定时间定时器、AnimatedContainer 的中间态
pumpAndSettle()循环 pump 直到没有待处理帧页面跳转、动画结束后的断言
pumpWidget()挂载整棵测试树每个测试的起点,内部会先 pump 一次

pumpAndSettle 遇到永不停止的动画(如循环的 CircularProgressIndicator 配合无限动画)会超时,此时应改用 pump(const Duration(milliseconds: 300))

mock 网络与平台通道

void main() {
  TestWidgetsFlutterBinding.ensureInitialized(); // 用通道 mock 必须先初始化绑定
  const channel = MethodChannel('com.example.app/device');

  setUp(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, (call) async {
      if (call.method == 'getBatteryLevel') return 88; // 假数据,测试不依赖真机
      return null;
    });
  });

  tearDown(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, null); // 不清理会影响其他测试
  });

  test('batteryLevel 正确解析原生返回值', () async {
    expect(await DeviceChannel.batteryLevel(), 88);
  });
}

网络层建议把 http.ClientDio 实例通过构造函数注入,测试时传入假实现;如果代码里直接用了 HttpClient,可以用 HttpOverrides.runZoned 拦截,但那属于补救手段。图片测试常因网络加载失败报错,用 mockNetworkImagesFor 或注入本地 asset 更稳。

集成测试与覆盖率

// integration_test/app_test.dart
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // 必须先调,否则收不到真实事件

  testWidgets('端到端:从列表进入详情', (tester) async {
    app.main(); // 启动真实入口,跑的是生产代码路径
    await tester.pumpAndSettle();
    await tester.tap(find.text('商品 A'));
    await tester.pumpAndSettle();
    expect(find.text('加入购物车'), findsOneWidget);
  });
}
flutter test                                   # 单元测试 + Widget 测试
flutter test --coverage                        # 生成 coverage/lcov.info
genhtml coverage/lcov.info -o coverage/html    # 需要 lcov 工具,产出 HTML 报告
flutter test integration_test/app_test.dart -d emulator-5554   # 指定设备跑集成测试
flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart  # 旧版驱动方式

CI 里跑测试的注意点:Golden 测试依赖字体渲染,Linux 容器与 macOS 上像素可能有细微差异,建议固定 CI 镜像并把 Golden 测试限制在少数关键页面上;集成测试需要模拟器或真机,通常放在夜间任务而不是每次提交;flutter test 默认并行执行,共享全局状态的测试要用 setUp/tearDown 清理干净。

常见坑

现象正确做法
忘了 await tester.pump()断言的是旧界面,测试假通过改状态后必须 pump 再断言
无脑用 pumpAndSettle有循环动画时超时失败改用定长 pump(Duration)
只用 find.text文案一改测试就挂关键控件加 Key,用 find.byKey
通道 mock 不清理影响后续测试,问题难定位tearDown 里把 handler 置回 null
假数据依赖真实网络测试随机失败注入 fake 客户端,测试内零网络

小结:测试投入按金字塔分配,纯逻辑用 test 加手写 fake、界面交互用 testWidgetsfind.byKey、关键链路用 integration_testpump 用于精确控制帧、pumpAndSettle 只在动画会停时使用,通道与网络一律在测试里 mock 掉,覆盖率用 flutter test --coverage 生成并在 CI 中回归。

笔记加载中…