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。
一、先定义测试对象、边界和可观察行为
设被测对象为 ,它依赖外部对象 ,测试输入为 ,系统输出为 。
一个隔离测试希望验证:
其中 是由测试控制的依赖替身,而不是不可预测的真实网络、时钟或数据库。
例如:
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;
}
}
}
这里有三个可观察状态:
- 调用前:
loading == false; - Future 未完成时:
loading == true; - Future 成功或失败后:
loading == false,并且分别设置name或error。
测试不应要求“必须使用 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时,;attempt = 2时,;attempt = 5时,,但最大值为 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 pump、pumpAndSettle 和异步状态
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 的“事件 → 状态”可以单独验证;页面只需验证给定 Loading、Data、Failure 状态时渲染什么。若每个 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 属性,而是某次渲染输出的像素。可形式化为:
其中:
- 是当前测试生成的像素;
- 是仓库中的基准图片;
- 是图像差异;
- 是比较器允许的差异阈值。具体阈值和比较行为由 Flutter 测试框架及其比较器实现决定,不能把 Golden test 理解成“任何相似图片都通过”。
如果 goldens/error_card.png 不存在,第一次运行会失败。确认当前输出确实是期望结果后,可更新基准:
flutter test --update-goldens test/error_card_golden_test.dart
更新 Golden 的风险是把真实回归一并写入基准文件。安全流程应是:
- 先正常运行,确认差异;
- 查看生成的差异图或实际截图;
- 判断变化来自有意设计、环境变化还是代码错误;
- 只有确认后才使用
--update-goldens; - 将 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:它仍然使用 testWidgets、pumpWidget 和测试绑定,只是最终观察从“树和状态”扩展为“像素”。
两类断言解决的问题不同:
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,也不会模拟原生系统弹窗。
因此,跨平台测试通常分成两层:
- 共享业务逻辑和大部分 Widget 行为,在统一测试环境中验证;
- 对平台特有能力,在对应平台设备或模拟器上运行 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);
});
}
生成代码需要在项目中配置 mockito、build_runner,然后运行:
dart run build_runner build
如果生成文件与源文件发生冲突,可按项目情况使用 build_runner 的冲突处理参数;关键是生成文件应由构建工具管理,不应手工修改生成结果。
这里的每一步有明确意义:
MockAnalytics是Analytics的替身;when规定调用后的行为;- 被测代码调用接口;
verify检查交互契约;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 失败
优先检查:
- 是否漏掉
await; - 是否共享了可变全局状态;
- 是否依赖当前时间、随机数或环境变量;
- Fake 是否真的模拟了失败路径;
- 异常是否在被测 Future 中抛出,而不是在测试结束后才抛出。
9.2 Widget test 找不到 Widget
错误类似:
Expected: exactly one matching node in the widget tree
Actual: _WidgetTypeFinder:<Found 0 widgets ...>
诊断顺序通常是:
- 是否调用了
await tester.pumpWidget(...); - 页面是否需要额外的 Provider、
MaterialApp或MediaQuery; - 异步状态是否已经
pump; - Finder 是否过于精确或文本已本地化;
- Widget 是否被条件分支排除;
- 页面是否有
mounted检查导致状态没有更新。
如果错误是 No MaterialLocalizations found、No 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 测试体系可以归纳为四个判断:
-
如果规则不需要 Flutter,优先写 Unit test。
测试状态、输入、输出、异常和并发边界。 -
如果问题是 Widget 树如何响应状态和交互,写 Widget test。
使用pump推进框架,显式控制异步依赖,验证用户可观察结果。 -
如果问题是视觉结构是否意外变化,写 Golden test。
固定尺寸、主题、字体和状态;把基准图片当作有审查成本的契约。 -
如果问题跨越真实应用、原生插件或平台生命周期,写 Integration test。
在对应 Android、iOS、桌面或 Web 环境验证,不把测试绑定 Mock 误认为真实平台验证。
Mock、Stub 和 Fake 位于这些测试类型之内,用来控制依赖边界。最稳定的设计通常不是“Mock 越多越好”,而是先定义清晰的接口,再让不同测试在合适边界观察状态、视觉、交互和平台结果。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 文件与媒体:选择、上传、图片、视频、相机和生命周期
- 下一篇:Flutter 性能优化:帧流水线、重建、栅格、内存和 DevTools
- 延伸:Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界
- 延伸:Flutter 大型应用架构:分层、Feature、依赖注入和多端边界
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论