Files
conti-docs/09-testing.md
T

149 lines
7.4 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 掉 `Dio`(或用 dio 自带的 `DioAdapter`/假响应),验证请求参数拼装和响应解析是否正确,不发真实网络请求。
- **presentation 层(Notifier**:用 `ProviderContainer` + `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` 都触发)。
## 参考链接
- [Flutter 官方测试文档](https://docs.flutter.dev/testing)
- [mocktail | Dart package](https://pub.dev/packages/mocktail)
- [integration_test 官方文档](https://docs.flutter.dev/testing/integration-tests)
## 附录:分层怎么测,日常怎么写
给还没接触过这套测试分层习惯的同学看的入门说明。
### 为什么要分层测
不同层次的代码,"测试成本"和"能捕获的问题"是不对称的: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('超时'));
final container = ProviderContainer(
overrides: [storeRepositoryProvider.overrideWithValue(repository)],
);
addTearDown(container.dispose);
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——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。