Files
conti-docs/09-testing.md
T

7.4 KiB
Raw Blame History

09. 测试策略

决策

采用三层测试金字塔,覆盖顺序从多到少:单元测试domain 业务规则 + Riverpod Notifier> Widget 测试(关键页面的 loading/data/error 状态)> 集成测试(仅覆盖 1-2 条黄金路径,如登录→下单→支付)。Mock 框架统一用 mocktail^1.0.5),不用 mockito——避免再引入一套 build_runner codegen 目标(项目里 riverpod_generator/drift_dev/pigeon 已经用了 codegenmocktail 不需要生成代码,减少构建链路复杂度)。

依赖

dev_dependencies:
  mocktail: ^1.0.5
  test: any          # 纯 Dart 单元测试
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

分层测试规则

  • domain 层(有 domain 的 featureuse case 用纯 Dart 单元测试,mock 掉 repository 接口,覆盖多步骤业务规则的分支(如 02-layering.mdConfirmPaymentUseCase 的状态校验、金额校验)。
  • 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 独有的能力

原生 iOSXCTest2013 年至今)和 AndroidJUnit + 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——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。