Add architecture documentation and retail system workshop PPT

This commit is contained in:
Guangfei.Zhao
2026-08-12 17:49:57 +08:00
commit 54c001a793
26 changed files with 9618 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
# 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——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。