149 lines
7.4 KiB
Markdown
149 lines
7.4 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 掉 `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 年至今)和 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('超时'));
|
||
|
||
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——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。
|