Files
conti-docs/09-testing.md
T
Guangfei.Zhao 444db49818 feat: add engineering conventions and CI gates documentation
- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks.
- Updated README to include the new conventions document.
- Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability.
- Adjusted global exception handling to return numeric error codes.
- Updated tests to reflect changes in error code handling.
2026-08-13 19:28:36 +08:00

255 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 年至今)和 AndroidJUnit + [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% 是起点,跑一个迭代后按实际情况调。