255 lines
14 KiB
Markdown
255 lines
14 KiB
Markdown
# 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<Override> 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<StateError>()));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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<AsyncError>());
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 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% 是起点,跑一个迭代后按实际情况调。
|