Flutter 基础体系 · 第 16/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 测试体系:Unit、Widget、Golden、Integration 和 Mock 边界

Flutter 测试不是把同一套断言运行在不同命令下,而是针对不同的系统边界观察不同的事实:

  • Unit test 观察一个 Dart 对象或函数的输入、输出和状态变化。
  • Widget test 观察 Widget 树在 Flutter 测试环境中的构建、布局、绘制、事件和语义。
  • Golden test 观察某个稳定渲染状态的像素结果。
  • Integration test 观察完整应用在真实平台环境中的用户路径,以及 Dart、Flutter Engine、原生插件之间的协作。
  • Mock 不是一种测试级别,而是一种测试替身技术。它决定测试如何隔离依赖,不能替代 Unit、Widget 或 Integration test。

这些类型之间不是简单的“越往后越真实”。它们验证的是不同契约:

flowchart LR
    U[Unit<br/>业务函数/状态对象] --> W[Widget<br/>Widget 树与交互]
    W --> G[Golden<br/>稳定视觉输出]
    W --> I[Integration<br/>真实应用与平台]
    D[依赖边界<br/>Repository / HTTP / Plugin] --> U
    D --> W
    D --> I

例如,一个按钮是否调用了 Repository,可以用 Widget test 验证;请求失败后页面是否显示错误,可以用 Fake Repository 验证;字体、间距和图标是否发生意外变化,可以用 Golden test 验证;登录跳转、系统键盘、原生存储是否协同工作,则通常需要 Integration test。


一、先定义测试对象、边界和可观察行为

设被测对象为 SS,它依赖外部对象 DD,测试输入为 II,系统输出为 OO

一个隔离测试希望验证:

O=S(I,D)O = S(I, D')

其中 DD' 是由测试控制的依赖替身,而不是不可预测的真实网络、时钟或数据库。

例如:

abstract interface class UserRepository {
  Future<String> loadDisplayName();
}

页面控制器依赖的是 UserRepository 接口,而不是 http.Client

class GreetingController {
  GreetingController(this.repository);

  final UserRepository repository;

  String? name;
  Object? error;
  bool loading = false;

  Future<void> load() async {
    loading = true;
    error = null;

    try {
      name = await repository.loadDisplayName();
    } catch (e) {
      error = e;
    } finally {
      loading = false;
    }
  }
}

这里有三个可观察状态:

  1. 调用前:loading == false
  2. Future 未完成时:loading == true
  3. Future 成功或失败后:loading == false,并且分别设置 nameerror

测试不应要求“必须使用 Dio”或“必须调用某个私有方法”,因为那是实现细节。更稳定的契约是:

给定一个成功的 UserRepository,最终显示名称;给定一个失败的 UserRepository,最终显示错误状态。

这也是 Mock 边界的核心:替身应放在应用拥有的抽象边界上,而不是随意替换任意对象。


二、Unit test:验证纯逻辑、状态对象和故障规则

2.1 Unit test 的范围

Unit test 通常不需要 Flutter Engine,也不构建 Widget 树。它适合验证:

  • 金额、日期、权限等纯函数;
  • 状态机和状态转换;
  • Repository 的数据映射逻辑;
  • 表单校验;
  • 重试、超时、缓存等规则;
  • ViewModel、Controller、BLoC 等非渲染对象。

Flutter 工程中可以直接使用 package:test/test.dart。如果测试需要 Flutter 的测试绑定或 Widget API,则使用 flutter_test

一个纯函数示例:

int retryDelaySeconds({
  required int attempt,
  int baseSeconds = 1,
  int maxSeconds = 30,
}) {
  if (attempt < 0) {
    throw ArgumentError.value(attempt, 'attempt');
  }

  final delay = baseSeconds * (1 << attempt);
  return delay > maxSeconds ? maxSeconds : delay;
}

测试:

import 'package:test/test.dart';

void main() {
  test('指数退避逐步增加,并受最大值限制', () {
    expect(retryDelaySeconds(attempt: 0), 1);
    expect(retryDelaySeconds(attempt: 1), 2);
    expect(retryDelaySeconds(attempt: 2), 4);
    expect(retryDelaySeconds(attempt: 5), 30);
  });

  test('attempt 不能为负数', () {
    expect(
      () => retryDelaySeconds(attempt: -1),
      throwsArgumentError,
    );
  });
}

这里可以推导出每个断言为何成立:

  • attempt = 0 时,1×20=11 \times 2^0 = 1
  • attempt = 2 时,1×22=41 \times 2^2 = 4
  • attempt = 5 时,1×25=321 \times 2^5 = 32,但最大值为 30,因此结果为 30;
  • 负数不是业务输入,函数通过 ArgumentError 拒绝它。

运行:

flutter test test/retry_delay_test.dart

预期结果类似:

00:01 +2: All tests passed!

具体耗时和编号会因测试文件而变化,不能把它们当作稳定输出。

2.2 异步 Unit test 必须等待 Future

异步测试需要把测试函数声明为 async,并等待被测 Future:

import 'package:test/test.dart';

class FakeUserRepository implements UserRepository {
  FakeUserRepository({this.result, this.exception});

  final String? result;
  final Object? exception;

  @override
  Future<String> loadDisplayName() async {
    if (exception != null) {
      throw exception!;
    }
    return result!;
  }
}

void main() {
  test('加载成功后设置名称并结束 loading', () async {
    final controller = GreetingController(
      FakeUserRepository(result: 'Ada'),
    );

    final future = controller.load();

    expect(controller.loading, isTrue);

    await future;

    expect(controller.loading, isFalse);
    expect(controller.name, 'Ada');
    expect(controller.error, isNull);
  });

  test('加载失败后保留错误并结束 loading', () async {
    final exception = StateError('network unavailable');
    final controller = GreetingController(
      FakeUserRepository(exception: exception),
    );

    await controller.load();

    expect(controller.loading, isFalse);
    expect(controller.name, isNull);
    expect(controller.error, same(exception));
  });
}

如果漏掉 await controller.load(),测试可能在 Future 完成之前就结束,导致:

  • 断言读到旧状态;
  • 异步异常没有按预期归入当前测试;
  • 测试偶尔通过、偶尔失败。

Future 的完成顺序是测试的一部分,不是可以忽略的实现细节。

2.3 时间、随机数和并发是 Unit test 的隐藏输入

下面这种实现难以稳定测试:

bool isExpired(DateTime expiresAt) {
  return DateTime.now().isAfter(expiresAt);
}

因为 DateTime.now() 是测试无法直接控制的外部输入。应注入时钟抽象:

abstract interface class Clock {
  DateTime now();
}

class SystemClock implements Clock {
  @override
  DateTime now() => DateTime.now();
}

bool isExpired(DateTime expiresAt, Clock clock) {
  return clock.now().isAfter(expiresAt);
}

测试可以使用 Fake:

class FixedClock implements Clock {
  FixedClock(this.value);

  final DateTime value;

  @override
  DateTime now() => value;
}

void main() {
  test('当前时间晚于过期时间时返回 true', () {
    final clock = FixedClock(DateTime.utc(2025, 1, 2));

    expect(
      isExpired(DateTime.utc(2025, 1, 1), clock),
      isTrue,
    );
  });
}

同理,随机数、设备方向、网络状态、文件系统和平台 API 都是隐含输入。把它们放入边界后,测试才能明确控制输入和故障。


三、Widget test:在测试绑定中验证 Widget 树和交互

3.1 Widget test 并不等于真实设备测试

Widget test 使用 flutter_test 提供的测试 API,并在测试绑定中构建 Widget 树:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('点击按钮后显示完成文本', (tester) async {
    var done = false;

    await tester.pumpWidget(
      MaterialApp(
        home: StatefulBuilder(
          builder: (context, setState) {
            return Scaffold(
              body: Center(
                child: Column(
                  children: [
                    Text(done ? '完成' : '未完成'),
                    ElevatedButton(
                      onPressed: () => setState(() => done = true),
                      child: const Text('提交'),
                    ),
                  ],
                ),
              ),
            );
          },
        ),
      ),
    );

    expect(find.text('未完成'), findsOneWidget);

    await tester.tap(find.text('提交'));
    await tester.pump();

    expect(find.text('完成'), findsOneWidget);
  });
}

运行:

flutter test test/widget_test.dart

tester.tap 只是向 Widget 测试环境注入手势;pump() 才会推进一次框架处理,使状态变更后的构建、布局和绘制有机会完成。

Widget test 适合验证:

  • Widget 是否根据状态显示正确内容;
  • 点击、输入、滚动和导航是否触发预期行为;
  • Provider、InheritedWidget、Riverpod、BLoC 等依赖注入是否正确接入;
  • loading、empty、error、data 等 UI 状态;
  • 无障碍语义树中是否存在必要标签。

它不天然验证:

  • 真正的 Android/iOS 键盘;
  • 原生相机、定位、推送和系统权限;
  • 真实 GPU 光栅化结果;
  • 某个插件在具体设备上的行为。

3.2 pumppumpAndSettle 和异步状态

Flutter 测试中的 Widget 状态通常经历:

调用动作
  ↓
修改状态 / 创建 Future
  ↓
pump
  ↓
build → layout → paint
  ↓
断言

对于一个带异步加载的页面,可以使用可控的 Future:

class ControlledUserRepository implements UserRepository {
  final completer = Completer<String>();

  @override
  Future<String> loadDisplayName() => completer.future;
}

Widget 测试示例:

class GreetingPage extends StatefulWidget {
  const GreetingPage({
    required this.repository,
    super.key,
  });

  final UserRepository repository;

  @override
  State<GreetingPage> createState() => _GreetingPageState();
}

class _GreetingPageState extends State<GreetingPage> {
  String? name;
  Object? error;
  bool loading = false;

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    setState(() => loading = true);

    try {
      final value = await widget.repository.loadDisplayName();
      if (!mounted) return;
      setState(() => name = value);
    } catch (e) {
      if (!mounted) return;
      setState(() => error = e);
    } finally {
      if (mounted) {
        setState(() => loading = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    if (loading) {
      return const CircularProgressIndicator();
    }
    if (error != null) {
      return const Text('加载失败');
    }
    return Text(name ?? '没有数据');
  }
}

对应测试:

void main() {
  testWidgets('先显示 loading,Future 完成后显示名称', (tester) async {
    final repository = ControlledUserRepository();

    await tester.pumpWidget(
      MaterialApp(
        home: GreetingPage(repository: repository),
      ),
    );

    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    repository.completer.complete('Ada');
    await tester.pump();

    expect(find.text('Ada'), findsOneWidget);
    expect(find.byType(CircularProgressIndicator), findsNothing);
  });
}

这里不应直接使用 pumpAndSettle() 代替所有 pump()pumpAndSettle() 会持续推进框架,直到没有待处理帧;如果页面存在无限动画、循环进度条或持续监听,它可能超时。更重要的是,它会隐藏测试真正等待的原因。

适合使用 pumpAndSettle() 的情况是:测试明确知道动画最终会结束,例如打开一个短暂的路由过渡。对网络 Future、定时器和持续动画,使用可控依赖并显式 pump 更容易诊断。

3.3 find 验证的是树中的对象,不是任意字符串

常见 Finder 包括:

find.text('提交');
find.byType(ElevatedButton);
find.byKey(const Key('submit-button'));
find.bySemanticsLabel('提交订单');

find.text 可能找到多个相同文本;因此交互断言应尽量提供稳定边界:

ElevatedButton(
  key: const Key('submit-button'),
  onPressed: submit,
  child: const Text('提交'),
)

测试:

await tester.tap(find.byKey(const Key('submit-button')));

Key 的作用是稳定识别 Widget,不是让实现细节无限暴露。对用户可见且有无障碍意义的控件,优先确保语义标签正确;对重复列表项,再使用业务 ID 构造稳定 Key。

3.4 Widget、InheritedWidget、Provider、Riverpod 和 BLoC 的测试边界

状态管理工具的差异不会改变基本测试边界:

  • InheritedWidget:测试祖先提供的数据是否被后代读取,以及依赖变化时是否重建;
  • Provider:测试 Provider 覆盖的依赖是否注入,常用 Fake Repository 替换真实实现;
  • Riverpod:测试时可使用 ProviderScope 覆盖 provider,观察状态流转;
  • BLoC:事件到状态的规则适合 Unit test,Bloc 与页面的连接适合 Widget test;
  • ChangeNotifier:通知前后的状态转换适合 Unit test,页面响应适合 Widget test。

例如,BLoC 的“事件 → 状态”可以单独验证;页面只需验证给定 LoadingDataFailure 状态时渲染什么。若每个 Widget test 都启动真实 HTTP 请求,测试就同时承担了网络、序列化和 UI 的多个故障来源。


四、Golden test:把稳定渲染结果作为像素契约

4.1 Golden test 测量什么

Golden test 通常把 Widget 渲染成图像,并与仓库中的基准图片比较:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('错误卡片视觉保持稳定', (tester) async {
    await tester.binding.setSurfaceSize(const Size(360, 180));

    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: Center(
            child: Card(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: Text('加载失败'),
              ),
            ),
          ),
        ),
      ),
    );

    await expectLater(
      find.byType(Card),
      matchesGoldenFile('goldens/error_card.png'),
    );

    addTearDown(() => tester.binding.setSurfaceSize(null));
  });
}

这个测试比较的不是 Widget 的 Dart 属性,而是某次渲染输出的像素。可形式化为:

pass    Δ(Pactual,Pgolden)T\text{pass} \iff \Delta(P_{\text{actual}}, P_{\text{golden}}) \leq T

其中:

  • PactualP_{\text{actual}} 是当前测试生成的像素;
  • PgoldenP_{\text{golden}} 是仓库中的基准图片;
  • Δ\Delta 是图像差异;
  • TT 是比较器允许的差异阈值。具体阈值和比较行为由 Flutter 测试框架及其比较器实现决定,不能把 Golden test 理解成“任何相似图片都通过”。

如果 goldens/error_card.png 不存在,第一次运行会失败。确认当前输出确实是期望结果后,可更新基准:

flutter test --update-goldens test/error_card_golden_test.dart

更新 Golden 的风险是把真实回归一并写入基准文件。安全流程应是:

  1. 先正常运行,确认差异;
  2. 查看生成的差异图或实际截图;
  3. 判断变化来自有意设计、环境变化还是代码错误;
  4. 只有确认后才使用 --update-goldens
  5. 将 PNG 与测试代码一起提交并进行代码审查。

4.2 为什么 Golden test 容易受环境影响

像素输出依赖多个变量:

  • Flutter SDK 和 Skia 实现;
  • 操作系统和渲染后端;
  • 字体文件及字体 fallback;
  • device pixel ratio;
  • surface size;
  • 文本抗锯齿;
  • 主题、Material 版本和平台适配;
  • 图标字体或图片资源;
  • 光标、动画和时间。

因此,Golden test 的“相同”不是抽象 UI 相同,而是在约定渲染环境中的输出相同

为了减少不确定性,应显式设置尺寸、主题和状态:

await tester.binding.setSurfaceSize(const Size(390, 844));

await tester.pumpWidget(
  MaterialApp(
    theme: ThemeData.light(),
    home: const MyPage(),
  ),
);

如果测试依赖字体,应保证测试环境安装同一字体,并避免让本机字体 fallback 决定结果。跨操作系统直接共享同一批像素基准,可能由于字体和渲染差异产生误报。工程上可以:

  • 固定 Golden 生成和校验环境;
  • 为不同平台维护不同基准;
  • 减少依赖系统字体的文本截图;
  • 对动态时间、随机内容和网络图片使用固定输入;
  • 在截图前等待图片加载和动画完成。

4.3 Golden test 与 Widget test 的关系

Golden test 通常也是 Widget test:它仍然使用 testWidgetspumpWidget 和测试绑定,只是最终观察从“树和状态”扩展为“像素”。

两类断言解决的问题不同:

expect(find.text('加载失败'), findsOneWidget);

验证文本节点存在;而:

await expectLater(
  find.byType(MyPage),
  matchesGoldenFile('goldens/my_page.png'),
);

验证页面在指定条件下的视觉结果。

Golden 不应取代语义和行为断言。一个页面即使截图一致,也可能:

  • 按钮没有可访问标签;
  • 点击后没有触发正确事件;
  • 错误区域对屏幕阅读器不可见;
  • 只在特定尺寸下显示正确。

相反,仅靠大量像素截图也会让每次文案、字体或主题调整都产生大面积变更,增加审查成本。应把 Golden 限定在视觉结构确实重要的组件和页面状态上。


五、Integration test:验证完整应用路径和平台边界

5.1 Integration test 的观察范围

Integration test 运行的是完整应用,而不是单个 Widget。典型路径包括:

启动应用
  ↓
初始化 Flutter Engine
  ↓
建立插件和原生通道
  ↓
创建页面与依赖
  ↓
执行真实手势、输入和导航
  ↓
验证最终页面或平台结果

常见用途:

  • 登录后进入首页;
  • 从列表进入详情再返回;
  • 真实数据库迁移;
  • 深链接启动;
  • 文件选择、相机、定位、推送等插件流程;
  • Android/iOS 生命周期和权限交互;
  • 多页面流程中的状态持久化。

Flutter 官方的集成测试通常使用 integration_test 包。测试文件可放在 integration_test/

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:example/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('用户可以打开设置页', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    expect(find.text('首页'), findsOneWidget);

    await tester.tap(find.byKey(const Key('settings-button')));
    await tester.pumpAndSettle();

    expect(find.text('设置'), findsOneWidget);
  });
}

前置条件是应用的 pubspec.yaml 中加入 integration_test 开发依赖,并且应用代码提供可启动的 main()。通常运行:

flutter test integration_test/app_test.dart

也可以指定设备:

flutter devices
flutter test -d <device-id> integration_test/app_test.dart

flutter devices 用来确认 Flutter 能发现目标设备或模拟器;没有可用设备时,集成测试不能凭空模拟真实原生环境。

pumpAndSettle() 在集成测试中同样不是万能等待机制。若应用首页有持续动画、轮播或后台刷新,测试可能无法 settle。此时应等待具体可观察条件,例如某个 Key 出现,或使用明确的 pump 和超时控制。

5.2 集成测试中的启动、清理和数据隔离

真实应用测试最容易被上一次运行污染。例如第一次测试写入登录 token,第二次测试启动时已处于登录状态,测试就不再验证登录流程。

应明确决定测试数据策略:

  • 使用专用测试账号;
  • 每个测试前清理本地数据库和 secure storage;
  • 使用独立后端环境;
  • 通过深链接或测试入口直接建立已知状态;
  • 让测试顺序不影响结果。

如果一个测试必须依赖另一个测试先执行,说明测试边界不清晰。集成测试可以共享应用启动过程,但业务前置状态应能被显式建立。

原生资源也要清理。例如测试创建了文件、注册了监听器或打开了数据库连接,应在测试结束或应用退出路径中释放,否则后续测试可能遇到:

  • 端口被占用;
  • 文件锁未释放;
  • 数据库迁移状态错误;
  • 事件重复订阅。

5.3 Android、iOS、桌面和 Web 的差异

集成测试的能力取决于目标平台、Flutter 测试支持以及插件实现,不能假设一份测试在所有平台具有相同语义。

平台 主要差异
Android 需要可用模拟器或真机;权限、返回键、Activity 生命周期和原生插件行为具有平台特征。
iOS 需要 macOS、Xcode、模拟器或真机;权限弹窗、键盘、状态栏和后台生命周期可能不同。
桌面 窗口尺寸、鼠标右键、键盘快捷键、文件系统和窗口生命周期与移动端不同;插件支持取决于具体插件。
Web 浏览器渲染、URL、Cookie、IndexedDB、权限模型和 DOM/Canvas 行为不同;原生移动插件不能直接假定可用。

Widget test 默认不等价于上述任何一个真实平台。即使测试中设置了 TargetPlatform.iOS,也只是影响 Flutter 层的部分平台判断,不会把 Android 设备变成 iOS,也不会模拟原生系统弹窗。

因此,跨平台测试通常分成两层:

  1. 共享业务逻辑和大部分 Widget 行为,在统一测试环境中验证;
  2. 对平台特有能力,在对应平台设备或模拟器上运行 Integration test。

如果某插件在 Web 没有实现,测试应该在构建配置或依赖注入层明确排除、替换或跳过,而不是让它在运行到一半时以 MissingPluginException 失败。


六、Mock:控制依赖,不是伪造整个系统

6.1 Stub、Fake、Mock 和 Spy 的区别

这些术语经常被混用,但用途不同。

Stub 提供预先设定的返回值:

class SuccessfulUserRepository implements UserRepository {
  @override
  Future<String> loadDisplayName() async => 'Ada';
}

它回答的是:“依赖返回什么?”

Fake 是一个可运行但简化的实现,例如内存数据库:

class InMemoryUserRepository implements UserRepository {
  String? value;

  @override
  Future<String> loadDisplayName() async {
    final result = value;
    if (result == null) {
      throw StateError('user not found');
    }
    return result;
  }
}

它适合测试多个操作之间的因果关系,而不是只返回一个固定值。

Mock 通常记录调用,并允许测试指定调用行为:

when(mock.loadDisplayName()).thenAnswer((_) async => 'Ada');
verify(mock.loadDisplayName()).called(1);

它回答的是:“依赖是否被这样调用?”

Spy 是真实或半真实实现加调用记录,用于观察交互。具体分类在不同测试库中可能有不同命名,但“返回数据”和“验证交互”是两种不同测试意图。

6.2 什么时候应该用 Mock

适合使用 Mock 的边界通常具有以下特征:

  • 调用代价高,例如网络或支付服务;
  • 结果不稳定,例如当前时间、随机数或系统状态;
  • 具有破坏性,例如删除文件、发送消息;
  • 测试需要验证调用参数和次数;
  • 真实实现难以在当前测试中构造。

不适合 Mock 的对象包括:

  • 被测对象本身;
  • 语言中的简单值对象;
  • Flutter 内部大量框架类;
  • 没有稳定抽象、只为了测试而暴露的私有实现;
  • 测试应该验证的业务规则。

如果把整个 Widget 树都 Mock 掉,测试可能只证明“Mock 按照预设返回了预设结果”,而没有验证实际 UI。

6.3 使用 Mockito 生成类型安全的 Mock

mockito 为例,先定义抽象边界:

abstract interface class Analytics {
  Future<void> track(String event);
}

测试文件:

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

import 'analytics_mock_test.mocks.dart';

@GenerateNiceMocks([
  MockSpec<Analytics>(),
])
void main() {
  test('提交成功后记录一次事件', () async {
    final analytics = MockAnalytics();

    when(analytics.track('submit_success'))
        .thenAnswer((_) async {});

    await analytics.track('submit_success');

    verify(analytics.track('submit_success')).called(1);
    verifyNoMoreInteractions(analytics);
  });
}

生成代码需要在项目中配置 mockitobuild_runner,然后运行:

dart run build_runner build

如果生成文件与源文件发生冲突,可按项目情况使用 build_runner 的冲突处理参数;关键是生成文件应由构建工具管理,不应手工修改生成结果。

这里的每一步有明确意义:

  1. MockAnalyticsAnalytics 的替身;
  2. when 规定调用后的行为;
  3. 被测代码调用接口;
  4. verify 检查交互契约;
  5. verifyNoMoreInteractions 防止测试遗漏了额外调用。

但“调用一次”只有在业务契约确实要求一次时才有价值。若实现允许批量上报、重试或合并事件,过度断言调用次数会让测试绑定到实现细节。

6.4 Mock 的典型反模式:过度指定交互

假设业务要求只是“用户提交成功后记录提交事件”。下面的测试可能过度约束:

verify(analytics.track('button_pressed')).called(1);
verify(analytics.track('request_started')).called(1);
verify(analytics.track('request_finished')).called(1);
verify(analytics.track('submit_success')).called(1);

这些事件名和顺序都不是用户可观察契约,除非分析系统明确要求它们。实现一旦合并事件、改变埋点顺序,测试就失败,但用户功能没有回归。

更稳妥的测试是验证关键外部结果:

verify(analytics.track('submit_success')).called(1);

或者在更高层使用 Fake Analytics,检查最终收集到的事件集合,而不是检查所有内部调用瞬间。


七、用 Fake 连接 Unit、Widget 和 Integration

下面用一个依赖注入边界展示三种测试如何分工。

应用层接口:

abstract interface class AuthService {
  Future<bool> login(String username, String password);
}

页面:

class LoginPage extends StatefulWidget {
  const LoginPage({
    required this.authService,
    super.key,
  });

  final AuthService authService;

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final usernameController = TextEditingController();
  final passwordController = TextEditingController();

  bool loading = false;
  String? error;

  Future<void> submit() async {
    setState(() {
      loading = true;
      error = null;
    });

    try {
      final success = await widget.authService.login(
        usernameController.text,
        passwordController.text,
      );

      if (!mounted) return;

      setState(() {
        loading = false;
        error = success ? null : '用户名或密码错误';
      });
    } catch (_) {
      if (!mounted) return;

      setState(() {
        loading = false;
        error = '网络错误';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          TextField(
            key: const Key('username-field'),
            controller: usernameController,
          ),
          TextField(
            key: const Key('password-field'),
            controller: passwordController,
            obscureText: true,
          ),
          if (loading) const CircularProgressIndicator(),
          if (error != null) Text(error!),
          ElevatedButton(
            key: const Key('login-button'),
            onPressed: loading ? null : submit,
            child: const Text('登录'),
          ),
        ],
      ),
    );
  }
}

Widget test 使用 Fake 控制成功和失败:

class FakeAuthService implements AuthService {
  FakeAuthService({required this.result});

  final bool result;
  String? lastUsername;
  String? lastPassword;

  @override
  Future<bool> login(String username, String password) async {
    lastUsername = username;
    lastPassword = password;
    return result;
  }
}

void main() {
  testWidgets('登录成功后隐藏 loading 且不显示错误', (tester) async {
    final auth = FakeAuthService(result: true);

    await tester.pumpWidget(
      MaterialApp(home: LoginPage(authService: auth)),
    );

    await tester.enterText(
      find.byKey(const Key('username-field')),
      'ada',
    );
    await tester.enterText(
      find.byKey(const Key('password-field')),
      'secret',
    );

    await tester.tap(find.byKey(const Key('login-button')));
    await tester.pump();

    expect(auth.lastUsername, 'ada');
    expect(auth.lastPassword, 'secret');
    expect(find.byType(CircularProgressIndicator), findsNothing);
    expect(find.text('网络错误'), findsNothing);
    expect(find.text('用户名或密码错误'), findsNothing);
  });

  testWidgets('认证服务返回 false 时显示业务错误', (tester) async {
    final auth = FakeAuthService(result: false);

    await tester.pumpWidget(
      MaterialApp(home: LoginPage(authService: auth)),
    );

    await tester.tap(find.byKey(const Key('login-button')));
    await tester.pump();

    expect(find.text('用户名或密码错误'), findsOneWidget);
  });
}

这两个测试不需要真实服务器,却验证了:

  • 输入是否传到了认证边界;
  • 点击后是否进入异步流程;
  • 成功和业务失败的 UI 分支;
  • loading 是否恢复。

Integration test 则可以使用真实认证环境或专用测试后端,验证:

输入账号密码
→ 调用真实网络层
→ 服务端返回 token
→ 持久化登录状态
→ 跳转首页
→ 重启后仍保持登录

如果把这条链路全部放入 Widget test,测试会变慢且容易受网络影响;如果只用 Fake,则不能发现 token 持久化或原生安全存储的问题。


八、测试异步、动画、平台通道和故障路径

8.1 不要用固定延时等待业务完成

这种写法通常不稳定:

await Future<void>.delayed(const Duration(seconds: 1));
expect(find.text('完成'), findsOneWidget);

网络速度和设备负载变化会使它出现竞态。更好的方式是控制 Future:

final completer = Completer<void>();

// 触发动作后
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// 在测试中明确推进依赖
completer.complete();
await tester.pump();

如果必须等待条件,可以围绕具体条件设计,而不是等待一个拍脑袋的时间长度。

8.2 FakeAsync 不是所有异步操作的通用替代

Flutter 测试绑定通常对部分计时器和框架调度提供可控的测试时间;但真实 I/O、原生平台调用和某些 Future 不会因为测试时间推进就自动完成。

因此要区分:

  • Timer 驱动的纯逻辑:可以使用测试时钟或 FakeAsync
  • Flutter 动画:使用 pump 推进时间;
  • 网络、文件、插件:注入可控 Fake,或在 Integration test 中使用真实环境;
  • 永不结束的流:显式关闭订阅或提供终止条件。

8.3 平台通道 Mock 有明确边界

Widget test 中可以通过测试绑定模拟某些 MethodChannel 调用,但这只能验证 Dart 侧发送了什么消息,不能证明 Android/iOS 原生实现正确。

例如,Dart 侧调用:

const channel = MethodChannel('example/battery');

final level = await channel.invokeMethod<int>('getBatteryLevel');

测试中替换消息处理器,可以验证通道名称、方法名和参数。但以下问题仍需要平台测试:

  • 原生方法是否注册;
  • 权限拒绝如何返回;
  • Activity 或 UIViewController 生命周期是否正确;
  • 真机上返回值是否符合预期;
  • 插件在目标平台是否支持该 API。

把平台通道 Mock 当成真实插件测试,是常见误区。它只覆盖通道协议的一侧。


九、失败表现和诊断顺序

9.1 Unit test 失败

优先检查:

  1. 是否漏掉 await
  2. 是否共享了可变全局状态;
  3. 是否依赖当前时间、随机数或环境变量;
  4. Fake 是否真的模拟了失败路径;
  5. 异常是否在被测 Future 中抛出,而不是在测试结束后才抛出。

9.2 Widget test 找不到 Widget

错误类似:

Expected: exactly one matching node in the widget tree
Actual: _WidgetTypeFinder:<Found 0 widgets ...>

诊断顺序通常是:

  1. 是否调用了 await tester.pumpWidget(...)
  2. 页面是否需要额外的 Provider、MaterialAppMediaQuery
  3. 异步状态是否已经 pump
  4. Finder 是否过于精确或文本已本地化;
  5. Widget 是否被条件分支排除;
  6. 页面是否有 mounted 检查导致状态没有更新。

如果错误是 No MaterialLocalizations foundNo MediaQuery widget ancestor 等,说明测试没有提供页面运行所需的祖先环境,而不是业务代码一定错误。

9.3 Golden diff 失败

不要先执行 --update-goldens。先区分:

  • 尺寸变了;
  • 字体或平台变了;
  • 主题变了;
  • 动画尚未稳定;
  • 图片资源尚未加载;
  • 代码确实改变了视觉布局。

如果差异只出现在文字边缘,优先检查渲染环境和字体;如果差异集中在布局区域,检查约束、padding、主题和屏幕尺寸。

9.4 Integration test 失败

常见原因不一定在 Flutter Widget:

  • 设备未启动或连接断开;
  • Android/iOS 权限状态残留;
  • 原生插件未注册;
  • 网络环境不可用;
  • 测试账号被锁定;
  • 本地数据库版本与测试预期不一致;
  • Web 平台没有对应插件实现。

诊断时应记录平台、设备、Flutter 版本、应用构建模式和测试数据状态。集成测试失败若只保留“找不到某个文本”,通常不足以判断是导航错误、权限弹窗遮挡还是后端失败。


十、如何划分测试,而不是机械套用测试金字塔

“测试金字塔”表达的是成本和反馈速度的常见趋势,不是 Flutter 的规范要求。合理划分应根据故障的归属来决定。

假设一个支付流程包含:

金额计算
→ 表单状态
→ 支付 SDK 调用
→ 服务端确认
→ 订单页渲染

可以这样分配:

  • 金额计算:Unit test;
  • 表单状态转换:Unit test;
  • 表单输入和错误展示:Widget test;
  • 订单页视觉结构:Golden test;
  • 支付 SDK 的 Dart 调用协议:Mock 或 Fake;
  • Android/iOS SDK 权限、回调和真实支付沙箱:Integration test;
  • 服务端支付确认:后端契约测试或专用集成环境。

反例是只写一个“点击支付后看到成功页面”的 Integration test。它可以发现整条链路断了,却不能快速说明金额计算、表单状态、SDK 调用还是导航出了问题。

另一个反例是为每个页面写完整 Golden,却不测试错误状态和语义。截图覆盖了大量像素,但关键业务状态没有被直接断言。

测试数量不是质量的充分条件。更重要的是每个测试都能回答一个明确问题:

这个测试失败时,哪条契约被破坏,修复者能从失败信息判断下一步在哪里?


十一、提交前的工程检查

一个可执行的本地检查可以从快到慢运行:

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
flutter test --coverage
flutter test integration_test/app_test.dart

这些命令的职责不同:

  • dart format 检查格式是否符合 Dart 格式化器输出;
  • flutter analyze 检查静态分析问题;
  • flutter test 运行 Unit、Widget 和 Golden test;
  • --coverage 生成覆盖率数据,但覆盖率只能说明执行过哪些代码,不能证明断言有意义;
  • 最后一条在可用设备或目标环境中运行完整应用路径。

Golden 更新不应默认放进普通验证命令。普通 CI 应比较已有基准;基准更新应是显式操作,并要求审查 PNG 差异。

对于多平台项目,可以拆分验证矩阵:

Dart / Flutter 共享逻辑
  → Unit + Widget

固定渲染环境
  → Golden

Android 模拟器或真机
  → Android Integration

iOS 模拟器或真机
  → iOS Integration

桌面窗口和插件
  → 对应桌面 Integration

浏览器与 Web API
  → Web Integration

这样做的原因不是追求更多流水线,而是让平台特有故障在拥有该平台证据的环境中暴露。


十二、核心边界

Flutter 测试体系可以归纳为四个判断:

  1. 如果规则不需要 Flutter,优先写 Unit test。
    测试状态、输入、输出、异常和并发边界。

  2. 如果问题是 Widget 树如何响应状态和交互,写 Widget test。
    使用 pump 推进框架,显式控制异步依赖,验证用户可观察结果。

  3. 如果问题是视觉结构是否意外变化,写 Golden test。
    固定尺寸、主题、字体和状态;把基准图片当作有审查成本的契约。

  4. 如果问题跨越真实应用、原生插件或平台生命周期,写 Integration test。
    在对应 Android、iOS、桌面或 Web 环境验证,不把测试绑定 Mock 误认为真实平台验证。

Mock、Stub 和 Fake 位于这些测试类型之内,用来控制依赖边界。最稳定的设计通常不是“Mock 越多越好”,而是先定义清晰的接口,再让不同测试在合适边界观察状态、视觉、交互和平台结果。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。