Files
conti-docs/flutter-app/09-testing.md
T

14 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 掉 ApiClient(见 05-networking.md),验证请求参数拼装和响应解析是否正确,不发真实网络请求。拦截器本身(ApiResultInterceptor/AuthInterceptor/ErrorMappingInterceptor)单独测,用 DioAdapter 造假响应——401 刷新的串行逻辑必须有测试,它是最容易写错、出错代价最高的一段(见 05 里关于并发刷新会导致全设备登出的说明)。
  • presentation 层(Notifier:用 ProviderContainer.test() + 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 都触发)。

mocktail 的 registerFallbackValue

any() 匹配自定义类型的参数时,必须先 registerFallbackValue,否则运行时直接报错。这是 mocktail 最常见的踩坑点,而且报错信息不看文档很难对上号。

class FakePageQuery extends Fake implements PageQuery {}
class FakeCancelToken extends Fake implements CancelToken {}

void main() {
  setUpAll(() {
    // 每个会出现在 any() 位置的非基础类型都要注册一次,注册一次即可全局生效
    registerFallbackValue(FakePageQuery());
    registerFallbackValue(FakeCancelToken());
  });

  test('...', () {
    when(() => repo.fetchOrders(any(), cancelToken: any(named: 'cancelToken')))
        .thenAnswer((_) async => const PageResult(items: [], total: 0, page: 1));
  });
}

int/String/bool/double 这些基础类型不需要注册。约定:registerFallbackValue 统一写在包的 test/helpers/fallbacks.dart 里,各测试文件的 setUpAll 调用同一个 registerAllFallbacks(),避免每个文件各注册一遍、漏一个就挂。

测试里必须关掉 Riverpod 的自动重试

Riverpod 3 的 provider 失败后会自动重试(见 03-state-management.md)。虽然我们在 ProviderScope 上全局关掉了,但测试不走 main.dartProviderContainer 默认仍带着重试策略。后果是断言 AsyncError 的测试会 flaky,或者测试跑完报 "A Timer is still pending"。

统一在测试辅助里建 container:

// test/helpers/container.dart
ProviderContainer makeContainer({List<Override> overrides = const []}) =>
    ProviderContainer.test(
      retry: (_, __) => null, // 与线上 ProviderScope 的配置保持一致
      overrides: overrides,
    );

所有测试用 makeContainer(),不直接 ProviderContainer.test(...)——这样将来全局策略变了只改一处。

覆盖率门禁

# 根 pubspec.yaml 的 melos: scripts:
test:
  run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage
coverage:
  run: |
    dart pub global run coverde value -i coverage/lcov.info --min-coverage 60

阈值定 60%,只卡整体、不卡单文件。理由:

  • 卡单文件会逼着大家给 *.g.dart、纯展示 widget、toString() 这类东西补无意义的测试,产出的是"覆盖率数字"而不是"信心"。
  • 60% 不是终点,是不允许倒退的地板。真正该高覆盖的是 domain use case 和 repository,这两块应该接近 90%,靠 review 保证而不是靠数字。
  • 生成产物(**/*.g.dart)、生成的 pigeon 代码要从 lcov 里排除,否则数字会被生成代码稀释得没有参考价值。

覆盖率报告作为 CI artifact 上传,PR 上能看到(见 14-conventions-and-ci-gates.md)。

集成测试在 CI 的运行环境

平台 Runner 说明
Android 现有 Linux runner + Android Emulator 可行。用 avdmanager 起一个无头模拟器(-no-window -gpu swiftshader),或用 Docker 镜像。启动慢(1–3 分钟),所以只跑黄金路径
iOS 需要 mac runner 08-build-flavors.md 的 iOS 构建链路是同一个阻塞项。mac runner 落地前,iOS 集成测试手工在本机跑,并在发版 checklist 里列为必做项

集成测试的触发时机:不进每次 push 的流水线,只在合入 develop/main 和打 tag 时跑。每次 push 都跑模拟器,流水线时间会从 3 分钟涨到 10 分钟以上,实际效果是大家开始绕过 CI。

集成测试连的是 UAT 后端,需要一组固定的测试账号和测试门店,数据由后端侧准备并保证可重复(这一项要和后端对齐)。

JSBridge 的测试策略

core_webview 的 JSBridge(见 10-webview-h5.md)分两块测,不要试图在 CI 里跑真实 H5 页面

  1. 协议编解码 → 纯 Dart 单元测试{id, method, params} 的解析、未知 method 的处理、参数缺失/类型错误的报错、回包格式、来源域名校验——这些都是纯函数,不需要 WebView,覆盖率应该接近 100%。这是 JSBridge 里最容易出错也最好测的部分。
  2. 原生能力调用 → mock 掉 native_* 的公共 API 类。验证"H5 发来 scan 请求 → 调了 NativeScan.startScan → 回包格式正确",不真的起相机。
  3. 端到端联调 → 走契约用例,不进 melos run test。维护一个 H5 侧和 App 侧共用的 bridge 契约用例清单(12 项能力各一条),联调时人工逐条过,作为 checklist 而不是自动化测试。真起 WebView 加载真 H5 的自动化测试在 CI 上又慢又不稳定,投入产出比很差。

Golden 测试:core_ui 做,业务页面不做

结论:只对 core_ui 里的基础组件(按钮、输入框、卡片、状态占位图)写 golden 测试,feature_* 的业务页面不写。

理由:

  • core_ui 组件被所有 feature 复用,改一处影响面大,而它们的输出是稳定的——正是 golden 测试的适用场景。
  • 业务页面的 UI 改动频繁,golden 会变成"每次改 UI 都要 --update-goldens 一遍"的负担,而且没人真的去看那张图对不对,最后退化成走过场。
  • golden 图片对渲染环境敏感(字体、平台、Flutter 版本)。必须在 CI 里用固定环境生成和比对,本机生成的图传上去大概率对不上。所以 golden 测试只在 Linux runner 上跑,本地开发时用 --tags golden 排除掉。

字体要显式加载,不然 golden 里全是方块:

setUpAll(() async {
  await loadAppFonts(); // golden_toolkit 或自己写的 FontLoader 封装
});

参考链接

附录:分层怎么测,日常怎么写

给还没接触过这套测试分层习惯的同学看的入门说明。

为什么要分层测

不同层次的代码,"测试成本"和"能捕获的问题"是不对称的: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('超时'));

    // makeContainer 内部是 ProviderContainer.test(retry: (_, __) => null)
    // 自动 dispose + 关掉自动重试,否则这条断言会 flaky
    final container = makeContainer(
      overrides: [storeRepositoryProvider.overrideWithValue(repository)],
    );

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

待确认项

  • 集成测试用的 UAT 测试账号/测试门店,以及数据可重复性由后端保证的方式。
  • 覆盖率阈值 60% 是起点,跑一个迭代后按实际情况调。