# 09. 测试策略 ## 决策 采用三层测试金字塔,覆盖顺序从多到少:**单元测试**(domain 业务规则 + Riverpod `Notifier`)> **Widget 测试**(关键页面的 loading/data/error 状态)> **集成测试**(仅覆盖 1-2 条黄金路径,如登录→下单→支付)。Mock 框架统一用 **[mocktail](https://pub.dev/packages/mocktail)**(`^1.0.5`),不用 `mockito`——避免再引入一套 `build_runner` codegen 目标(项目里 `riverpod_generator`/`drift_dev`/`pigeon` 已经用了 codegen,`mocktail` 不需要生成代码,减少构建链路复杂度)。 ## 依赖 ```yaml dev_dependencies: mocktail: ^1.0.5 test: any # 纯 Dart 单元测试 flutter_test: sdk: flutter integration_test: sdk: flutter ``` ## 分层测试规则 - **domain 层(有 domain 的 feature)**:use case 用纯 Dart 单元测试,mock 掉 `repository` 接口,覆盖多步骤业务规则的分支(如 [02-layering.md](./02-layering.md) 里 `ConfirmPaymentUseCase` 的状态校验、金额校验)。 - **data 层**:repository 实现用单元测试,mock 掉 `ApiClient`(见 [05-networking.md](./05-networking.md)),验证请求参数拼装和响应解析是否正确,不发真实网络请求。拦截器本身(`ApiResultInterceptor`/`AuthInterceptor`/`ErrorMappingInterceptor`)单独测,用 `DioAdapter` 造假响应——**401 刷新的串行逻辑必须有测试**,它是最容易写错、出错代价最高的一段(见 05 里关于并发刷新会导致全设备登出的说明)。 - **presentation 层(Notifier)**:用 `ProviderContainer.test()` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。 - **Widget 测试**:只覆盖有实际业务分支的页面(比如列表的 loading/data/error 三态渲染是否正确),纯展示型 widget(无状态分支)不强制要求。 - **集成测试**:只覆盖黄金路径(1-2 条最核心的用户旅程),跑在真实/模拟设备上,验证跨 feature 的路由跳转和端到端流程;不追求覆盖所有页面组合,避免集成测试维护成本超过收益。 - 每个 `feature_*` 包的 `test/` 目录结构镜像 `lib/src/`(如 `test/domain/`、`test/data/`、`test/presentation/`),单元测试和 Widget 测试都通过 `melos run test`(见 [01-project-structure.md](./01-project-structure.md))统一跑;集成测试单独一个 CI job,不并入这条批量命令(跑得慢、需要设备/模拟器,不适合每次 `analyze`/`test` 都触发)。 ## mocktail 的 `registerFallbackValue` **用 `any()` 匹配自定义类型的参数时,必须先 `registerFallbackValue`**,否则运行时直接报错。这是 mocktail 最常见的踩坑点,而且报错信息不看文档很难对上号。 ```dart class FakePageQuery extends Fake implements PageQuery {} class FakeCancelToken extends Fake implements CancelToken {} void main() { setUpAll(() { // 每个会出现在 any() 位置的非基础类型都要注册一次,注册一次即可全局生效 registerFallbackValue(FakePageQuery()); registerFallbackValue(FakeCancelToken()); }); test('...', () { when(() => repo.fetchOrders(any(), cancelToken: any(named: 'cancelToken'))) .thenAnswer((_) async => const PageResult(items: [], total: 0, page: 1)); }); } ``` `int`/`String`/`bool`/`double` 这些基础类型不需要注册。约定:`registerFallbackValue` 统一写在包的 `test/helpers/fallbacks.dart` 里,各测试文件的 `setUpAll` 调用同一个 `registerAllFallbacks()`,避免每个文件各注册一遍、漏一个就挂。 ## 测试里必须关掉 Riverpod 的自动重试 Riverpod 3 的 provider 失败后会自动重试(见 [03-state-management.md](./03-state-management.md))。虽然我们在 `ProviderScope` 上全局关掉了,但**测试不走 `main.dart`,`ProviderContainer` 默认仍带着重试策略**。后果是断言 `AsyncError` 的测试会 flaky,或者测试跑完报 "A Timer is still pending"。 统一在测试辅助里建 container: ```dart // test/helpers/container.dart ProviderContainer makeContainer({List overrides = const []}) => ProviderContainer.test( retry: (_, __) => null, // 与线上 ProviderScope 的配置保持一致 overrides: overrides, ); ``` 所有测试用 `makeContainer()`,不直接 `ProviderContainer.test(...)`——这样将来全局策略变了只改一处。 ## 覆盖率门禁 ```yaml # 根 pubspec.yaml 的 melos: scripts: test: run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage coverage: run: | dart pub global run coverde value -i coverage/lcov.info --min-coverage 60 ``` 阈值定 **60%**,只卡**整体**、不卡单文件。理由: - 卡单文件会逼着大家给 `*.g.dart`、纯展示 widget、`toString()` 这类东西补无意义的测试,产出的是"覆盖率数字"而不是"信心"。 - 60% 不是终点,是**不允许倒退的地板**。真正该高覆盖的是 domain use case 和 repository,这两块应该接近 90%,靠 review 保证而不是靠数字。 - 生成产物(`**/*.g.dart`)、生成的 pigeon 代码要从 lcov 里排除,否则数字会被生成代码稀释得没有参考价值。 覆盖率报告作为 CI artifact 上传,PR 上能看到(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 ## 集成测试在 CI 的运行环境 | 平台 | Runner | 说明 | |---|---|---| | Android | 现有 **Linux** runner + Android Emulator | 可行。用 `avdmanager` 起一个无头模拟器(`-no-window -gpu swiftshader`),或用 Docker 镜像。启动慢(1–3 分钟),所以只跑黄金路径 | | iOS | 需要 **mac runner** | 与 [08-build-flavors.md](./08-build-flavors.md) 的 iOS 构建链路是同一个阻塞项。mac runner 落地前,iOS 集成测试**手工在本机跑**,并在发版 checklist 里列为必做项 | 集成测试的触发时机:**不进每次 push 的流水线**,只在合入 `develop`/`main` 和打 tag 时跑。每次 push 都跑模拟器,流水线时间会从 3 分钟涨到 10 分钟以上,实际效果是大家开始绕过 CI。 集成测试连的是 **UAT 后端**,需要一组固定的测试账号和测试门店,数据由后端侧准备并保证可重复(这一项要和后端对齐)。 ## JSBridge 的测试策略 `core_webview` 的 JSBridge(见 [10-webview-h5.md](./10-webview-h5.md))分两块测,**不要试图在 CI 里跑真实 H5 页面**: 1. **协议编解码 → 纯 Dart 单元测试**。`{id, method, params}` 的解析、未知 `method` 的处理、参数缺失/类型错误的报错、回包格式、来源域名校验——这些都是纯函数,不需要 WebView,覆盖率应该接近 100%。这是 JSBridge 里最容易出错也最好测的部分。 2. **原生能力调用 → mock 掉 `native_*` 的公共 API 类**。验证"H5 发来 `scan` 请求 → 调了 `NativeScan.startScan` → 回包格式正确",不真的起相机。 3. **端到端联调 → 走契约用例,不进 `melos run test`**。维护一个 H5 侧和 App 侧共用的 bridge 契约用例清单(12 项能力各一条),联调时人工逐条过,作为 checklist 而不是自动化测试。真起 WebView 加载真 H5 的自动化测试在 CI 上又慢又不稳定,投入产出比很差。 ## Golden 测试:`core_ui` 做,业务页面不做 **结论**:只对 `core_ui` 里的基础组件(按钮、输入框、卡片、状态占位图)写 golden 测试,`feature_*` 的业务页面不写。 理由: - `core_ui` 组件被所有 feature 复用,改一处影响面大,而它们的输出是稳定的——正是 golden 测试的适用场景。 - 业务页面的 UI 改动频繁,golden 会变成"每次改 UI 都要 `--update-goldens` 一遍"的负担,而且没人真的去看那张图对不对,最后退化成走过场。 - golden 图片对**渲染环境敏感**(字体、平台、Flutter 版本)。必须在 CI 里用固定环境生成和比对,本机生成的图传上去大概率对不上。所以 golden 测试**只在 Linux runner 上跑**,本地开发时用 `--tags golden` 排除掉。 字体要显式加载,不然 golden 里全是方块: ```dart setUpAll(() async { await loadAppFonts(); // golden_toolkit 或自己写的 FontLoader 封装 }); ``` ## 参考链接 - [Flutter 官方测试文档](https://docs.flutter.dev/testing) - [mocktail | Dart package](https://pub.dev/packages/mocktail) - [mocktail: registerFallbackValue](https://pub.dev/packages/mocktail#how-it-works) - [integration_test 官方文档](https://docs.flutter.dev/testing/integration-tests) - [Flutter: golden 文件测试](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html) ## 附录:分层怎么测,日常怎么写 给还没接触过这套测试分层习惯的同学看的入门说明。 ### 为什么要分层测 不同层次的代码,"测试成本"和"能捕获的问题"是不对称的:domain 层的一条业务规则用纯 Dart 单元测试几毫秒就能跑完,覆盖所有分支;同样的规则如果只写在集成测试里验证,跑一次要几十秒甚至更久(要真的启动 App、走完整个页面流程),而且大部分时间花在跟这条业务规则无关的 UI 渲染上。**金字塔的意思是:能在下层用低成本测试覆盖的逻辑,就不要指望上层的少量集成测试兜底**——集成测试数量少,只用来确认"各层拼在一起没有断裂",不负责覆盖业务规则细节。 ### 这不是 Flutter 独有的能力 原生 iOS([XCTest](https://developer.apple.com/documentation/xctest),2013 年至今)和 Android(JUnit + [Espresso](https://developer.android.com/training/testing/espresso)/[Robolectric](http://robolectric.org/))的单元测试、UI 自动化测试工具链其实比这里用的这套还要成熟。真正决定"业务逻辑好不好单独测"的是**架构**,不是工具:传统 MVC/MVP 项目里业务逻辑和 `ViewController`/`Activity` 强耦合(网络回调直接写在 `viewDidLoad`/`onCreate` 里),想测一条规则得连带整个页面生命周期一起启动测试环境,成本高、写起来别扭。domain 层纯 Dart、UI 状态与业务逻辑分离,本质是分层架构把业务逻辑从 UI 里解耦的结果——同样的分层思路(Clean Architecture + MVVM)搬到原生 iOS/Android 上,一样能达到这种测试体验。 ### 单元测试示例:domain use case ```dart class MockPaymentRepository extends Mock implements PaymentRepository {} void main() { late MockPaymentRepository repository; late ConfirmPaymentUseCase useCase; setUp(() { repository = MockPaymentRepository(); useCase = ConfirmPaymentUseCase(repository); }); test('订单状态非 pending 时应抛出 StateError', () async { when(() => repository.fetchOrder('order1')).thenAnswer( (_) async => PaymentOrder(orderId: 'order1', amountCents: 100, status: PaymentStatus.paid), ); expect(() => useCase.call('order1', 'pin'), throwsA(isA())); }); test('校验通过时应调用 confirmPayment', () async { when(() => repository.fetchOrder('order1')).thenAnswer( (_) async => PaymentOrder(orderId: 'order1', amountCents: 100, status: PaymentStatus.pending), ); when(() => repository.confirmPayment('order1', 'pin')).thenAnswer((_) async {}); await useCase.call('order1', 'pin'); verify(() => repository.confirmPayment('order1', 'pin')).called(1); }); } ``` ### 单元测试示例:Riverpod Notifier ```dart void main() { test('刷新失败时状态应变为 AsyncError', () async { final repository = MockStoreRepository(); when(() => repository.fetchNearbyStores(any(), any())) .thenThrow(NetworkException('超时')); // makeContainer 内部是 ProviderContainer.test(retry: (_, __) => null): // 自动 dispose + 关掉自动重试,否则这条断言会 flaky final container = makeContainer( overrides: [storeRepositoryProvider.overrideWithValue(repository)], ); await container.read(storeListNotifierProvider.future).catchError((_) {}); final state = container.read(storeListNotifierProvider); expect(state, isA()); }); } ``` ### Widget 测试示例:门店列表三态 ```dart void main() { testWidgets('加载失败时应展示错误文案', (tester) async { final repository = MockStoreRepository(); when(() => repository.fetchNearbyStores(any(), any())) .thenThrow(NetworkException('网络异常')); await tester.pumpWidget(ProviderScope( overrides: [storeRepositoryProvider.overrideWithValue(repository)], child: const MaterialApp(home: StoreListPage()), )); await tester.pumpAndSettle(); expect(find.textContaining('加载失败'), findsOneWidget); }); } ``` ### 集成测试示例:黄金路径骨架 ```dart void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('登录 -> 浏览门店 -> 完成支付', (tester) async { await tester.pumpWidget(const ProviderScope(child: App())); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('login_username')), 'test_user'); await tester.tap(find.byKey(const Key('login_submit'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('store_item_0'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('confirm_payment'))); await tester.pumpAndSettle(); expect(find.text('支付成功'), findsOneWidget); }); } ``` 集成测试用真实的(或半真实的、通过测试环境后端的)依赖跑通整条链路,不 mock 掉 repository——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。 ## 待确认项 - 集成测试用的 UAT 测试账号/测试门店,以及数据可重复性由后端保证的方式。 - 覆盖率阈值 60% 是起点,跑一个迭代后按实际情况调。