7.4 KiB
09. 测试策略
决策
采用三层测试金字塔,覆盖顺序从多到少:单元测试(domain 业务规则 + Riverpod Notifier)> Widget 测试(关键页面的 loading/data/error 状态)> 集成测试(仅覆盖 1-2 条黄金路径,如登录→下单→支付)。Mock 框架统一用 mocktail(^1.0.5),不用 mockito——避免再引入一套 build_runner codegen 目标(项目里 riverpod_generator/drift_dev/pigeon 已经用了 codegen,mocktail 不需要生成代码,减少构建链路复杂度)。
依赖
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 里ConfirmPaymentUseCase的状态校验、金额校验)。 - data 层:repository 实现用单元测试,mock 掉
Dio(或用 dio 自带的DioAdapter/假响应),验证请求参数拼装和响应解析是否正确,不发真实网络请求。 - presentation 层(Notifier):用
ProviderContainer+overrides直接测试Notifier/AsyncNotifier的状态流转(见 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)统一跑;集成测试单独一个 CI job,不并入这条批量命令(跑得慢、需要设备/模拟器,不适合每次analyze/test都触发)。
参考链接
附录:分层怎么测,日常怎么写
给还没接触过这套测试分层习惯的同学看的入门说明。
为什么要分层测
不同层次的代码,"测试成本"和"能捕获的问题"是不对称的:domain 层的一条业务规则用纯 Dart 单元测试几毫秒就能跑完,覆盖所有分支;同样的规则如果只写在集成测试里验证,跑一次要几十秒甚至更久(要真的启动 App、走完整个页面流程),而且大部分时间花在跟这条业务规则无关的 UI 渲染上。金字塔的意思是:能在下层用低成本测试覆盖的逻辑,就不要指望上层的少量集成测试兜底——集成测试数量少,只用来确认"各层拼在一起没有断裂",不负责覆盖业务规则细节。
这不是 Flutter 独有的能力
原生 iOS(XCTest,2013 年至今)和 Android(JUnit + Espresso/Robolectric)的单元测试、UI 自动化测试工具链其实比这里用的这套还要成熟。真正决定"业务逻辑好不好单独测"的是架构,不是工具:传统 MVC/MVP 项目里业务逻辑和 ViewController/Activity 强耦合(网络回调直接写在 viewDidLoad/onCreate 里),想测一条规则得连带整个页面生命周期一起启动测试环境,成本高、写起来别扭。domain 层纯 Dart、UI 状态与业务逻辑分离,本质是分层架构把业务逻辑从 UI 里解耦的结果——同样的分层思路(Clean Architecture + MVVM)搬到原生 iOS/Android 上,一样能达到这种测试体验。
单元测试示例:domain use case
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
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 测试示例:门店列表三态
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);
});
}
集成测试示例:黄金路径骨架
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——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。