第一次在技术面试中被问到“你的测试覆盖率是多少?”时,我没有给出好答案。
到那时,我已经交付了几个真实的 Flutter 应用。它们运行正常,用户也在使用。但我的测试,如果能称之为测试的话,只是针对我曾经栽过跟头的定价函数写的少量单元测试,除此之外什么也没有。
几个月后,我重构了一个任务完成流程(在 diff 中看起来完全安全),结果破坏了用户实际上最关心的一点:标记任务完成后,它被移到了错误的列表。程序没有崩溃,也没有记录任何错误。用户只是悄悄地不再信任该应用。我之所以发现,是因为他们告诉了一位恰好也是 beta 测试者的朋友。
这就是测试真正要捕获的 bug。不是崩溃,因为崩溃会被报告。那种在发布时看似无误却悄悄破坏信任的回归,只有在有东西在监视时才能被发现。
自那以后,我又交付了几个应用,现在我会有意识地进行测试:不是测试一切,而是测试那些曾经让我吃过亏的东西。
本文围绕一个真实特性,介绍 Flutter 提供的四种测试类型(单元、widget、golden 和集成),并在四个层面上进行测试。我们这样做的原因是,阅读四段脱节的代码片段永远无法让我明白这些层次应该如何配合。只有把它们堆叠在同一个特性上,我才终于恍然大悟。
目录
先决条件
在学习本教程之前,您应该对以下内容感到熟悉:
基本的 Dart 和 Flutter 语法: 类、async/await 以及构建简单的 widget。这不是一个从零开始的 Flutter 教程,并且假设您已经能够构建一个屏幕。也许您只是还没有正确地测试过它。
Provider/ChangeNotifier 模式 或类似的东西(Riverpod、Bloc 等等):
TaskNotifierextendsChangeNotifier,并且示例假设您对这种状态管理方式感到熟悉,即使您自己的应用使用不同的实现。
您还需要安装并设置以下内容:
Flutter SDK (最近的稳定版本。示例不依赖于任何前沿特性。)
带有 Flutter/Dart 支持的编辑器 (VS Code 或 Android Studio 都可以)
设备或模拟器 专门用于集成测试部分。iOS 模拟器或 Android 模拟器就足够了。您不需要实际硬件。
以下的开发依赖项,它们会在需要时逐步介绍,但随手拥有它们是值得的:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
mocktail: ^1.0.4
golden_toolkit: ^0.15.0
如果你能在空项目上运行 flutter test 并且它能干净退出,那就说明你已经准备好了。
为什么需要四种测试,而不仅仅是“测试”
我早期读过的每个 Flutter 测试教程都把“测试”当作一种活动来对待。其实不然。这四种类型分别回答四个不同的问题,混淆它们是导致测试感觉要么是过度杀伤,要么是浪费时间的原因,这取决于你实际上正在进行哪种类型的测试。
单元测试 能回答:给定输入时,这段特定的逻辑是否能产生正确的输出?不涉及 widget、渲染或模拟设备。仅仅是一个函数或类以及一个断言。这些测试运行时间以毫秒计,若需要可运行成千上万次。
Widget 测试 能回答:在特定状态下,这个 widget 是否能正确渲染并表现行为?它们在没有真实设备或真实像素的模拟环境中运行。并且它们足够快,可以在每次保存时运行,但又足够真实,能够捕获 “请求失败时重试按钮不会出现” 这样的问题。
金色测试 能回答:这个 widget 仍然 看起来 符合预期吗?它们会将渲染后的 widget 与保存的参考图像逐像素比较。这是四种测试中唯一能够捕获 “内边距现在出错” 或 “文本溢出” 问题的一种——这些问题是人眼明显可见的,但对 find.text() 断言来说是不可见的。
集成测试 能回答:编译后在真实或模拟设备上运行的真实应用是否真的可以端到端地工作?它们运行较慢,成本也相对较高,并且是四种测试中唯一能够捕获仅在层之间交互时才会出现的 bug 的一种——例如,一个仓库返回了错误的类型给一个负责正确渲染的 notifier。
这四种测试互不替代。定价错误应放在单元测试中。缺失的错误状态应放在 widget 测试中。布局偏移应放在金色测试中。端到端流程中断应放在集成测试中。如果只使用其中一种测试,那么另外三类错误将漏网而不被发现。
我们正在测试的功能
为了保持具体性,每个部分都基于同一个功能:一个任务列表,用户可以在其中标记任务为已完成,而已完成的数量会在应用栏中反映出来。
// lib/task.dart
class Task {
const Task({required this.id, required this.title, this.isDone = false});
final String id;
final String title;
final bool isDone;
Task copyWith({bool? isDone}) =>
Task(id: id, title: title, isDone: isDone ?? this.isDone);
}
// lib/task_repository.dart
abstract class TaskRepository {
Future> fetchTasks();
Future setTaskDone(String id, bool isDone);
}
// lib/task_logic.dart
/// The bug I actually shipped: this used to filter on the wrong
/// field when a task list contained tasks from more than one list,
/// silently completing a task in the wrong place. A single unit
/// test on this function would have caught it before it shipped.
int countCompleted(List tasks) =>
tasks.where((t) => t.isDone).length;
List markDone(List tasks, String id) => tasks
.map((t) => t.id == id ? t.copyWith(isDone: true) : t)
.toList();
// lib/task_notifier.dart
class TaskNotifier extends ChangeNotifier {
TaskNotifier(this._repository);
final TaskRepository _repository;
List _tasks = [];
bool isLoading = false;
String? error;
List get tasks => _tasks;
int get completedCount => countCompleted(_tasks);
Future load() async {
isLoading = true;
error = null;
notifyListeners();
try {
_tasks = await _repository.fetchTasks();
} catch (_) {
error = 'Failed to load tasks. Please try again.';
}
isLoading = false;
notifyListeners();
}
Future complete(String id) async {
final previous = _tasks;
_tasks = markDone(_tasks, id); // optimistic update
notifyListeners();
try {
await _repository.setTaskDone(id, true);
} catch (_) {
_tasks = previous; // roll back on failure
notifyListeners();
}
}
}
// lib/task_screen.dart
class TaskScreen extends StatefulWidget {
const TaskScreen({super.key, required this.notifier});
final TaskNotifier notifier;
@override
State createState() => _TaskScreenState();
}
class _TaskScreenState extends State {
@override
void initState() {
super.initState();
widget.notifier.load();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.notifier,
builder: (context, _) {
final notifier = widget.notifier;
return Scaffold(
appBar: AppBar(title: Text('Tasks (${notifier.completedCount} done)')),
body: notifier.isLoading
? const Center(child: CircularProgressIndicator())
: notifier.error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(notifier.error!),
const SizedBox(height: 8),
ElevatedButton(
onPressed: notifier.load,
child: const Text('Retry'),
),
],
),
)
: ListView(
children: notifier.tasks
.map((task) => CheckboxListTile(
key: ValueKey(task.id),
title: Text(task.title),
value: task.isDone,
onChanged: task.isDone
? null
: (_) => notifier.complete(task.id),
))
.toList(),
),
);
},
);
}
}
这就是全部功能。现在让我们用四种不同的方式来测试它。
单元测试:隔离的业务逻辑
countCompleted 和 markDone 是纯 Dart 函数,零 Flutter 依赖:没有 BuildContext、widgets 或任何需要测试设备的东西。这是刻意的:如此重要的逻辑不应该需要渲染引擎来验证。
// test/task_logic_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/task.dart';
import 'package:my_app/task_logic.dart';
void main() {
group('countCompleted', () {
test('returns 0 for an empty list', () {
expect(countCompleted([]), 0);
});
test('counts only tasks marked done', () {
final tasks = [
const Task(id: '1', title: 'A', isDone: true),
const Task(id: '2', title: 'B', isDone: false),
const Task(id: '3', title: 'C', isDone: true),
];
expect(countCompleted(tasks), 2);
});
});
group('markDone', () {
test('marks only the task with the matching id', () {
final tasks = [
const Task(id: '1', title: 'A'),
const Task(id: '2', title: 'B'),
];
final result = markDone(tasks, '2');
// The critical assertion — this is the exact bug I shipped.
// A naive implementation that filters on the wrong field
// would mark task '1' done instead, or both, and this
// test would fail immediately instead of surfacing in
// a user's bug report three weeks later.
expect(result.firstWhere((t) => t.id == '1').isDone, false);
expect(result.firstWhere((t) => t.id == '2').isDone, true);
});
test('returns an unchanged list if the id does not exist', () {
final tasks = [const Task(id: '1', title: 'A')];
final result = markDone(tasks, 'nonexistent');
expect(result.first.isDone, false);
});
});
}
使用以下命令运行:
flutter test test/task_logic_test.dart
每个测试只需几毫秒即可完成。没有理由跳过编写此类测试。成本几乎可以忽略不计,而正是这一层,错误的假设会悄然进入生产环境,因为当逻辑出现细微错误时,界面并不会有任何不同的表现。复选框仍然可以切换,只是它切换的是错误的任务。
有一个值得尽早养成的习惯:使用 group 和参数化风格的循环,而不是复制粘贴几乎相同的测试。我曾经为同一个函数的五种边界情况编写了五个几乎相同的测试函数,而在重构后,这五个测试中必有一个会与其他测试失去同步。
group('markDone with various ids', () {
final cases = {
'1': true, // exists, should be marked done
'2': false, // exists, different id, should stay unchanged
'x': false, // does not exist, should be a no-op
};
for (final entry in cases.entries) {
test('id ${entry.key} resolves to isDone=${entry.value}', () {
final tasks = [
const Task(id: '1', title: 'A'),
const Task(id: '2', title: 'B'),
];
final result = markDone(tasks, '1');
final target = result.where((t) => t.id == entry.key);
if (target.isEmpty) {
// The 'x' case — id doesn't exist, list should be unaffected
expect(result.length, tasks.length);
} else {
expect(target.first.isDone, entry.value);
}
});
}
});
对于两三种情况来说,这并不是严格必需的,但当一个函数有五六个值得测试的分支时,使用循环可以保持意图的可读性,并且让添加第六种情况只需一行改动,而不是复制粘贴一个容易被遗忘更新的测试函数。
测试异步逻辑和异常
真实应用中大多数有趣的逻辑并不是纯同步函数。它们是异步的,并且可能会失败。flutter_test 的 test() 原生处理返回 Future 的函数体,这比人们预期的更容易,但在它变得自动之前,我曾反复犯下两个错误。
test('setTaskDone throws for an unknown task id', () async {
final repository = FakeTaskRepository();
// expect() with throwsA works on synchronous throws.
// For a Future that completes with an error, you need
// expectLater with throwsA, or the async matcher form below.
await expectLater(
() => repository.setTaskDone('nonexistent', true),
throwsA(isA()),
);
});
test('fetchTasks returns an empty list, not null, when there is nothing to fetch', () async {
final repository = FakeTaskRepository(seed: []);
final result = await repository.fetchTasks();
// This looks trivial, but I've genuinely shipped a null check
// in a widget that assumed an empty repository always threw
// instead of returning []. One line here would have caught it.
expect(result, isEmpty);
expect(result, isNotNull);
});
我最常见的早期错误:在没有 await 前面的情况下写 expect(() => someAsyncFunction(), throwsA(...))。因为被测试的函数是异步的,异常会被抛入一个尚未解决的 Future 中,而此时同步的 expect() 已经运行。即使代码出错,测试也会悄悄地通过,因为根本没有等待失败发生。将 expectLater 与 await 结合使用才是真正触发失败路径的版本。
Widget 测试:无需设备的 UI
TaskScreen 需要在加载、显示错误或显示数据时正确渲染。并且它需要一个假的 TaskRepository 来在不进行真实网络请求的情况下实现这一点。mocktail 是 Dart 中目前的标准做法,因为它不像旧的 mocking 方法那样需要代码生成。
dev_dependencies:
mocktail: ^1.0.4
// test/task_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_app/task.dart';
import 'package:my_app/task_notifier.dart';
import 'package:my_app/task_repository.dart';
import 'package:my_app/task_screen.dart';
class MockTaskRepository extends Mock implements TaskRepository {}
void main() {
late MockTaskRepository repository;
setUp(() {
repository = MockTaskRepository();
});
testWidgets('shows a loading indicator while fetching', (tester) async {
// A Completer that never resolves keeps the widget in the
// loading state for the duration of this specific test.
repository.fetchTasks; // registered below via when()
when(() => repository.fetchTasks())
.thenAnswer((_) => Completer>().future);
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
// pump() advances exactly one frame — enough to see the
// loading state, without waiting for anything to resolve.
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows tasks once loaded', (tester) async {
when(() => repository.fetchTasks()).thenAnswer(
(_) async => [
const Task(id: '1', title: 'Buy milk'),
const Task(id: '2', title: 'Walk the dog', isDone: true),
],
);
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
// pumpAndSettle waits for all pending frames and microtasks —
// the right call once you want to assert on the final,
// settled state rather than a specific frame along the way.
await tester.pumpAndSettle();
expect(find.text('Buy milk'), findsOneWidget);
expect(find.text('Tasks (1 done)'), findsOneWidget);
});
testWidgets('shows an error state with a working retry button', (tester) async {
when(() => repository.fetchTasks()).thenThrow(Exception('network error'));
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
await tester.pumpAndSettle();
expect(find.text('Failed to load tasks. Please try again.'), findsOneWidget);
// Now make the retry succeed, and confirm tapping Retry
// actually recovers — not just that the button exists.
when(() => repository.fetchTasks())
.thenAnswer((_) async => [const Task(id: '1', title: 'Buy milk')]);
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
expect(find.text('Buy milk'), findsOneWidget);
expect(find.text('Failed to load tasks. Please try again.'), findsNothing);
});
testWidgets('completing a task updates the done count', (tester) async {
when(() => repository.fetchTasks()).thenAnswer(
(_) async => [const Task(id: '1', title: 'Buy milk')],
);
when(() => repository.setTaskDone('1', true)).thenAnswer((_) async {});
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
await tester.pumpAndSettle();
expect(find.text('Tasks (0 done)'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(find.text('Tasks (1 done)'), findsOneWidget);
});
}
重试测试是值得关注的。很容易在看到“重试按钮出现”时就停下——但这只能证明按钮存在,而不能证明点击它会有任何效果。只有继续操作并断言恢复后的状态,才能真正防止因重试按钮绑定了错误的回调而导致的问题,这确实是一个容易犯的错误。
常见的 Widget 测试错误
我曾多次犯过这些错误,而且往往不止一次。
1. 误用 pump() 而本意是使用 pumpAndSettle(),或相反。
pump() 只会推进一帧。pumpAndSettle() 会持续推帧,直到没有待重建的任务——这正是异步操作完成后所需要的;但如果 widget 树中有持续动画的元素(例如 CircularProgressIndicator),它会无限等待下去,最终触发超时。
我曾花费二十分钟才弄清楚测试为何超时,原来是加载 spinner 本身——它是一个无限动画——阻止了 pumpAndSettle 看到稳定帧。
在这种情况下的解决办法是:当被测试的 widget 包含合法地永不停止的动画时,改为固定次数调用 pump(),或使用显式时长的 pump(duration),而不是 pumpAndSettle()。
// This will time out if the tree contains a CircularProgressIndicator,
// which animates forever and never "settles."
await tester.pumpAndSettle();
// This advances a fixed number of frames instead — the right
// choice when you specifically want to catch the loading state
// mid-flight rather than wait for it to resolve.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
2. 当使用 Key 更稳定时,通过文本查找小部件
find.text('Buy milk') 一旦产品文案改动,或在未来测试中两个任务恰好有相同标题,就会失效。我现在会为测试需要可靠查找的任何元素添加键,就像上面的 CheckboxListTile 通过 ValueKey(task.id) 添加键一样:find.byKey(const ValueKey('1')) 不关心任务的标题是什么。
3. 忘记 MaterialApp 会包裹每个涉及 Theme.of(context) 或 Navigator 的 widget 测试。
A raw pumpWidget(TaskScreen(...)) 没有 MaterialApp 祖先组件时,第一次尝试依赖它们的操作会抛出关于缺少 Directionality 或 Navigator 的令人困惑的错误。这是一个错误消息,前几次遇到时并不明确指出应该“将其包装在 MaterialApp 中”。
测试文本输入和滚动
以下两种交互场景出现得足够频繁,值得单独举例:在字段中输入文本,以及滚动以显示屏幕外的内容。
testWidgets('typing a name and submitting calls the repository', (tester) async {
final repository = MockTaskRepository();
when(() => repository.fetchTasks()).thenAnswer((_) async => []);
when(() => repository.addTask(any())).thenAnswer((_) async {});
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
await tester.pumpAndSettle();
// enterText simulates typing directly — no need to simulate
// individual keystrokes for the overwhelming majority of tests.
await tester.enterText(find.byKey(const Key('new_task_field')), 'Buy milk');
await tester.tap(find.byKey(const Key('add_task_button')));
await tester.pumpAndSettle();
verify(() => repository.addTask('Buy milk')).called(1);
});
testWidgets('scrolling reveals a task below the fold', (tester) async {
final repository = MockTaskRepository();
when(() => repository.fetchTasks()).thenAnswer(
(_) async => List.generate(
30,
(i) => Task(id: '$i', title: 'Task $i'),
),
);
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
await tester.pumpAndSettle();
// Task 25 is off-screen on first render in a 30-item list.
expect(find.text('Task 25'), findsNothing);
// scrollUntilVisible repeatedly scrolls a fixed amount and
// checks after each attempt — the right tool when you don't
// know exactly how far to scroll to reach a specific item.
await tester.scrollUntilVisible(
find.text('Task 25'),
500.0,
scrollable: find.byType(Scrollable),
);
expect(find.text('Task 25'), findsOneWidget);
});
黄金测试:捕获视觉回归
到目前为止的所有测试都检查 行为:正确的文本是否出现或正确的计数是否更新。它们都无法捕捉到在小屏幕上导致复选框列表溢出其容器的更改,或导致重试按钮被推出屏幕外的内边距调整。这就是黄金测试存在的原因。
黄金测试会渲染一个组件,并逐像素地与保存的参考图像进行比较。
// test/task_screen_golden_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_app/task.dart';
import 'package:my_app/task_notifier.dart';
import 'package:my_app/task_repository.dart';
import 'package:my_app/task_screen.dart';
class MockTaskRepository extends Mock implements TaskRepository {}
void main() {
testWidgets('task screen with data matches the golden file', (tester) async {
final repository = MockTaskRepository();
when(() => repository.fetchTasks()).thenAnswer(
(_) async => [
const Task(id: '1', title: 'Buy milk'),
const Task(id: '2', title: 'Walk the dog', isDone: true),
],
);
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
await tester.pumpAndSettle();
// On first run, this generates the reference image.
// On every run after, it fails if a single pixel differs.
await expectLater(
find.byType(TaskScreen),
matchesGoldenFile('goldens/task_screen_with_data.png'),
);
});
testWidgets('task screen error state matches the golden file', (tester) async {
final repository = MockTaskRepository();
when(() => repository.fetchTasks()).thenThrow(Exception('error'));
await tester.pumpWidget(MaterialApp(
home: TaskScreen(notifier: TaskNotifier(repository)),
));
await tester.pumpAndSettle();
await expectLater(
find.byType(TaskScreen),
matchesGoldenFile('goldens/task_screen_error.png'),
);
});
}
使用以下命令生成初始参考图像:
flutter test --update-goldens test/task_screen_golden_test.dart
将生成的 .png 文件与测试一起提交。此后,flutter test 会执行比较而不是重新生成。如果以后的更改导致像素偏移,测试会失败并显示差异,而不是让队友在三个冲刺后才发现真实设备上的屏幕略有偏差。
在大量依赖此方法之前,有两点需要注意。首先,不同机器和 CI 运行器之间的字体和渲染可能会有细微差异,这会导致与你的代码无关的误报。在一致的 Docker 镜像中运行金色测试,或使用像 golden_toolkit 这样的包(它会规范化字体加载),可以解决大部分问题。
其次,在活跃开发过程中频繁变化的界面上维护金色测试成本较高。我只将它们用于稳定且高可见度的界面,而不是所有界面,因为每次布局微调都重新生成金色测试会失去其意义。
多设备和深色模式金色测试
单张金色图片只能证明在特定屏幕尺寸和主题下界面显示正确。我通过这种方式实际发现的 bug 是:任务标题在标准手机宽度下能完整截断,但在小屏幕设备上会超出九个像素,直到有人使用较老、较窄的手机提交支持工单才被发现。
golden_toolkit 的 multiScreenGolden 在一个测试中跨多种设备尺寸渲染相同的 widget,这是我在进行任何金色测试时使用的版本:
dev_dependencies:
golden_toolkit: ^0.15.0
testGoldens('task screen across device sizes', (tester) async {
final repository = MockTaskRepository();
when(() => repository.fetchTasks()).thenAnswer(
(_) async => [const Task(id: '1', title: 'Buy milk, eggs, and bread')],
);
final builder = DeviceBuilder()
..overrideDevicesForAllScenarios(devices: [
Device.phone, // narrow — this is the one that caught the overflow
Device.iphone11,
Device.tabletLandscape,
])
..addScenario(
widget: MaterialApp(home: TaskScreen(notifier: TaskNotifier(repository))),
name: 'with data',
);
await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(tester, 'task_screen_multi_device');
});
如果你的应用支持深色模式,深色模式同样值得同样的对待。硬编码的文本颜色在深色背景下不可见,这是一种真实且令人尴尬的错误类别;如果所有的黄金测试只在亮色模式下渲染,则这种错误完全看不见:
testGoldens('task screen in dark mode', (tester) async {
final repository = MockTaskRepository();
when(() => repository.fetchTasks()).thenAnswer(
(_) async => [const Task(id: '1', title: 'Buy milk')],
);
await tester.pumpWidgetBuilder(
TaskScreen(notifier: TaskNotifier(repository)),
wrapper: materialAppWrapper(theme: ThemeData.dark()),
);
await tester.pumpAndSettle();
await screenMatchesGolden(tester, 'task_screen_dark_mode');
});
防止金色测试成为维护负担
我观察到多个团队出现的一种失败模式是:金色测试会在一个月内被热情添加;随后一次合法的设计更改触及了在十五个屏幕上共享的组件。于是十五个金色测试同时失败,团队直接运行 --update-goldens,却没有真的逐个审查每个差异。毕竟,在截止日期压力下,逐个审查十五张图片的差异感觉不值得花时间。
正是这一瞬间,金色测试停止了对你的保护,因为此后团队的反射性反应变成了“重新生成并继续前进”,而不是“查看是什么变化并确认这是有意的”。
有两件事可以防止这种情况发生。首先,保持金色测试集小而精准地选择。我上面已经提到过,但这一点重要到值得重复:选择五到六个高价值的屏幕,而不是五十个。
其次,将一批金色测试失败视为打开差异的信号,而不是一个待勾选的复选框。大多数用于金色测试的 CI 设置可以将差异图片作为构建制品上传,以便审阅者在拉取请求中直接查看,而无需在本地拉取分支。
集成测试:完整的应用,端到端
单元测试和 widget 测试在模拟的 Dart 环境中运行。没有真实的渲染引擎或平台通道,假的仓库充当网络。这就是它们快速的原因,也是它们无法捕获的地方:真实的应用在真实设备或模拟器上编译运行时,当每一层真正连接在一起时,是否真的能够工作。
dev_dependencies:
integration_test:
sdk: flutter
// integration_test/complete_task_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('user can load tasks and complete one, end to end', (tester) async {
// This runs your actual main() — the real app, the real
// repository implementation, whatever backend it's wired to
// in this build (typically a staging environment for CI).
app.main();
await tester.pumpAndSettle();
expect(find.text('Buy milk'), findsOneWidget);
expect(find.textContaining('0 done'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(find.textContaining('1 done'), findsOneWidget);
});
}
在真机或模拟器上运行:
flutter test integration_test/complete_task_test.dart
这会慢很多(秒而不是毫秒),因为它在编译并运行真实的应用,而不是模拟的 widget 树。正是这种开销决定了集成测试应该只覆盖少数几个关键流程——如果它们出错会真正造成伤害的流程,比如完成购买或登录(也就是说,应用存在的核心动作),而不是遍历每一个界面。在真实项目中,我通常会跑五到六个这样的测试,覆盖我在用户遇到问题之前就想了解的流程。
不稳定性、重试和真实设备
集成测试的失败方式与其他三种类型大不相同:它们往往是间歇性的,且与 bug 无关。CI 模拟器运行变慢、预发布环境中的网络请求比测试预期多耗半秒,或者下一个动作触发时动画仍未结束:所有这些情况都会导致失败,而这些失败与应用是否真的能工作无关。
一个习惯让我免受最多的挫败感:永远不要在没有 pumpAndSettle()(或显式的 pump(duration))的情况下直接将 tester.tap() 链接到另一个交互,即使这看起来是多余的。
// Flaky: if the tap triggers any async work (a network call, an
// animation), the next find() can run before it resolves,
// and the test fails unpredictably depending on machine speed.
await tester.tap(find.byType(CheckboxListTile).first);
expect(find.textContaining('1 done'), findsOneWidget);
// Reliable: explicitly wait for everything triggered by the tap
// to finish before asserting on the result.
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(find.textContaining('1 done'), findsOneWidget);
就 CI 而言,在真实设备农场(Firebase Test Lab,或连接到 CI 运行器的真实设备)上运行集成测试能够捕获模拟器有时完全遗漏的一类错误:权限对话框表现不同、相机或生物识别提示,以及仅在真实硬件上出现的内存压力。这也是最昂贵且最慢的方案,因此我选择按计划运行(每晚或在发布前),而不是在每次提交时都运行。
如果您的应用足够复杂,需要更丰富的集成测试工具(原生权限处理、生物识别模拟或更深层的平台交互),那么 patrol 值得一看。它基于 integration_test 构建,但提供了基础包不具备的功能。
每种测试类型实际上何时会发挥作用
在使用这种四层方法交付了几款应用后,我大致的精力分配方式及原因如下:
单元测试,广泛使用。 编写和运行几乎没有成本,而且它们是唯一能够在逻辑错误出现任何可视表现之前捕获它的层。每一个非平凡的定价计算、过滤器和业务逻辑都应该拥有一个单元测试。
针对每个具有多于一种状态的界面进行 widget 测试。 加载、错误和成功是三条不同的代码路径,每条路径都是错误可能悄然隐藏的地方。如果一个界面只有一种状态,widget 测试的价值较低。如果它有三种状态,跳过其中两种相当于跳过了界面实际行为的三分之二。
谨慎且有目的地使用黄金测试。 我仅在视觉回归会让人真感尴尬的界面上使用它们,例如结账流程或用户每次会话都会看到的核心界面。我不会为应用中的每个界面都使用黄金测试,因为维护成本是真实存在的,且并非每种布局都值得被固定下来。
针对定义应用的少数关键流程进行集成测试。 这不是全面覆盖,但足以确保当所有真实层连接在一起时,应用实际要实现的功能仍然正常工作。
慢慢削弱测试套件的错误
这些问题不会立即导致构建失败。但如果长期不加以解决,每个月都会让测试套件的可信度降低,就像一个缺乏结构的代码库会随着时间的推移变得越来越难以修改,尽管在任何一天都不会彻底崩溃。
第一个错误是 mock 了你实际上想要测试的对象。我曾见过(并且自己也写过)一个对仓库的“单元测试”,它将 HTTP 客户端 mock 得如此彻底,以至于测试实际上只是在断言 Dio 自身的客户端行为符合 Dio 文档的描述。如果一个测试在你的代码存在真实 bug 时不可能失败,那么它就不是在测试你的代码。
第二是跳过失败路径,因为设置起来麻烦。本文中的每个界面都有加载状态、错误状态和成功状态,我曾看到团队(包括我自己,早期的时候)只为成功状态编写 widget 测试,因为这是最容易设置的。错误状态恰恰是最可能出现真实 bug 的路径,因为开发者在手动测试中自己最少会走这条路。
将不稳定的测试视为需要重试而非修复的问题也是一种错误。二十次运行中只失败一次、重新运行后通过的测试并不是“偶尔不稳定”。它在告诉你代码或测试对时机的假设中存在真实的竞态条件。在 CI 中通过自动重试来屏蔽它,会让整个团队停止信任红色构建,这比不稳定测试本身的问题代价大得多。
最后,编写断言实现细节而非行为的测试也是一种错误。检查 notifier._tasks.length(一个私有字段)而非 notifier.tasks.length 或渲染后的 UI 的测试,会将测试与内部结构绑定,而这些内部结构本不应直接被测试。当你在不改变实际行为的情况下重构内部表示时,测试会因与真实 bug 无关的原因而失败。
一起运行所有内容
一个按顺序运行所有四个步骤、先跑最便宜的 Makefile 或 CI 脚本,能够在昂贵的步骤开始之前捕获大多数问题:
# Fails fast on logic bugs before spending time on anything else.
flutter test test/task_logic_test.dart
# Widget-level behavior across all three UI states.
flutter test test/task_screen_test.dart
# Visual regressions on the screens that matter.
flutter test test/task_screen_golden_test.dart
# The real thing, on a real device or emulator — last, because it's slowest.
flutter test integration_test/complete_task_test.dart
在 CI 中,前三个测试我会在每个 pull request 上运行——它们足够快,没理由不跑。集成测试套件则安排在合并到 main 分支时或每夜定时运行,因为它需要真实设备或模拟器,耗时也长;如果让每个 PR 都被它阻塞,团队节奏会被拖慢,而这类 bug 用一套完善的 widget 测试大多数时候本来就能捕获。
端到端:四层测试跑在同一条 CI 流水线上
下面是我在一个真实项目中把这套流程接入 GitHub Actions 的实际做法。快速检查排在最前并设为门禁,这样任何阶段一旦失败,流水线立即中止,不会浪费时间继续跑下一阶段:
# .github/workflows/test.yml
name: Test
on: [pull_request, push]
jobs:
unit-and-widget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- run: flutter pub get
# Unit and widget tests together — both fast, both run on
# every PR without a second thought about cost.
- run: flutter test test/task_logic_test.dart test/task_screen_test.dart
golden:
runs-on: ubuntu-latest
needs: unit-and-widget
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- run: flutter pub get
- run: flutter test test/task_screen_golden_test.dart
# Upload diffs so a reviewer can see exactly what changed
# without pulling the branch locally — this is what keeps
# golden failures from becoming a rubber-stamped --update-goldens.
- uses: actions/upload-artifact@v4
if: failure()
with:
name: golden-diffs
path: test/failures/
integration:
runs-on: macos-latest # needed for iOS simulator; use ubuntu + Android emulator otherwise
needs: golden
if: github.ref == 'refs/heads/main' # only on merges to main, not every PR
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- run: flutter pub get
- run: flutter test integration_test/complete_task_test.dart -d "iPhone 15"
在这里,集成作业上的 needs: 链和 if: 条件发挥着实际作用:它们阻止了最慢、最昂贵的检查在每一次推送时运行,同时仍然保证它在任何内容合并到 main 之前执行。
最后的想法
我曾经把“测试”当作一个单一的条目,要么做要么不做,仪表盘上的一个百分比。其实不然。单元测试保护逻辑。组件测试保护状态。黄金测试保护像素。集成测试保证所有这些在真实设备上能够真正协同工作。
这些在真实功能上设置好后其实并不难编写,这就是我围绕一个完整的功能来构建本文,而不是提供四个互不相关的代码片段的原因。引发本文的任务完成错误(任务被错误地标记为完成在错误的列表中)本来可以通过在 markDone 上编写的单元测试捕获,而该测试是在我接触组件层之前就写好的。
我最初并没有编写那个测试。现在,我在每个重要的功能上都会编写它及其兄弟测试。这实际上就是全部的教训:测试本身并不复杂,而这四种测试各自回答了其他三种无法回答的问题。