《Flutter 测试入门:单元测试与 widget test》
本文做什么,不做什么
给一个普通 Flutter 应用从零配上测试:纯逻辑跑单元测试、界面跑 widget test,最后 integration_test 和 CI 各一段带过。边界先说死:golden 图像比对不展开;CI 只给 GitHub Actions 的关键步骤,不做多平台矩阵;性能测试(integration_test 的 timeline)也不在本文范围。版本口径:Flutter 3.22(2024-05-14 GA,Google I/O 发布,配套 Dart 3.4),flutter_test 与 integration_test 都是 SDK 自带,mock 用 mocktail。看完能拿到的是一套可复制的样板:一个 model 的单元测试、一个计数组件和一个待办项组件的完整 widget 测试,全部带预期输出。
三层测试,各管一段
Flutter 官方把测试分三层,分工搞混是新手最常见的内耗来源:
| 层次 | 测什么 | 跑在哪 | 速度 |
|---|---|---|---|
| 单元测试(test) | 纯 Dart 逻辑:model、工具函数、状态计算 | Dart VM,无界面 | 毫秒级 |
| widget test | 单个组件:渲染、交互、状态更新 | 假环境(无 GPU、假时钟) | 毫秒到秒级 |
| integration test | 整个应用:真实设备上走完整流程 | 真机 / 模拟器 | 秒到分钟级 |
我的配比:能用单元测试测的逻辑绝不上 widget test,能用 widget test 测的交互绝不上 integration test——上层测试慢一个量级,坏一个测试的定位成本也贵一个量级。这个层次感在《Flutter 学习路线》阶段四的验收标准里就有雏形:那份验收表里的每一条,其实都该落成用例,本文就是把”落成用例”这件事教会。
第一条单元测试
拿待办应用里的 model 开刀。先看被测代码:
// lib/models/todo.dart
class TodoItem {
TodoItem({required this.title, DateTime? createdAt})
: createdAt = createdAt ?? DateTime.now() {
if (title.trim().isEmpty) {
throw ArgumentError('标题不能为空');
}
}
final String title;
final DateTime createdAt;
bool done = false;
void toggle() => done = !done;
}
测试文件放 test/ 目录,镜像 lib 结构。flutter_test 自带 test()、group() 和整套 matcher:
// test/models/todo_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:todo_app/models/todo.dart';
void main() {
group('TodoItem', () {
test('空白标题拒绝创建', () {
expect(() => TodoItem(title: ' '), throwsArgumentError);
});
test('toggle 翻转完成状态', () {
final todo = TodoItem(title: '买牛奶');
expect(todo.done, isFalse);
todo.toggle();
expect(todo.done, isTrue);
});
test('createdAt 可注入固定值', () {
final t = DateTime(2024, 6, 1);
expect(TodoItem(title: 'a', createdAt: t).createdAt, t);
});
});
}
跑起来:
flutter test test/models/todo_test.dart
预期输出:
00:01 +3: All tests passed!
三条用例钉住三件事:非法输入的拒绝路径、状态翻转、可注入时间(为将来测”今天到期”这类逻辑留口子)。写用例的顺序我习惯先钉边界再补常规——空白标题这条的价值比”正常创建”那条大,因为它是唯一会炸的地方。语法细节拿不准时翻《Dart 语法要点》。
widget test:pumpWidget、finder 与断言
widget test 的骨架固定三步:pumpWidget 把组件挂上去、finder 找东西、expect 断言。先看被测组件:
// lib/counter_page.dart
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$_count', key: const Key('count')),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => setState(() => _count++),
),
],
),
),
);
}
}
测试:
// test/counter_page_test.dart
void main() {
testWidgets('初始计数为零', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
expect(find.byKey(const Key('count')), findsOneWidget);
expect(find.text('0'), findsOneWidget);
});
testWidgets('点击加号计数加一', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
expect(find.text('0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // 触发一帧重建
expect(find.text('1'), findsOneWidget);
});
}
预期输出:
00:01 +2: All tests passed!
常用 finder 就五个,背下来够用大半年:find.text('买牛奶')、find.byType(Checkbox)、find.byIcon(Icons.add)、find.byKey(Key('count'))、find.widgetWithText(ElevatedButton, '提交')。断言后缀配四件套:findsOneWidget、findsNothing、findsNWidgets(2)、findsWidgets(至少一个)。
新手第一坑在这儿:把 await tester.pump() 那行删掉,用例立刻红,报错长这样:
Expected: exactly one matching node in the widget tree
Actual: WidgetFinder:<zero widgets with text "1">
原因一句话:setState 之后 Flutter 没有立刻重绘,要等下一帧;widget test 里帧不会自己走,得用 pump 手动推。手点应用时感觉不到这个间隙,测试环境替你把它暴露出来了——这不是测试的毛病,是它诚实的证明。
pump 与 pumpAndSettle:异步帧的两把钥匙
pump() 推进一帧,可以带时长:await tester.pump(const Duration(seconds: 2)) 表示把假时钟拨快两秒再渲染一帧。widget test 里时钟是假的,Duration 就是个可以随便拨的旋钮——这是 Flutter 测试体系最讨我喜欢的设计:Future.delayed 一秒的业务逻辑,测试里拨一秒表就过,真实等待为零。
pumpAndSettle() 则是”推帧推到没有动画任务为止”,适合验证动画结束后的最终状态。它的死穴:遇到永不停止的动画(转圈 loading、无限轮播)会一直推到超时,用例像卡死。我的习惯:拿不准就用带时长的 pump,能写清楚”拨多少”就不要”等到不动”。
FutureBuilder 的例子把两件事串起来:
class _GoodsPageState extends State<GoodsPage> {
late final Future<String> _future;
@override
void initState() {
super.initState();
_future = Future.delayed(const Duration(seconds: 1), () => '商品 A');
}
// build 里 FutureBuilder(future: _future, ...),有数据显示名称,
// 否则显示'加载中…'
}
testWidgets('加载完成后显示数据', (tester) async {
await tester.pumpWidget(const MaterialApp(home: GoodsPage()));
expect(find.text('加载中…'), findsOneWidget); // 初始帧
await tester.pump(const Duration(seconds: 2)); // 拨过延时
expect(find.text('商品 A'), findsOneWidget);
});
顺带钉一个真 bug 的复现:如果把 future 挪进 build 里现造,每次重建都是新 Future,测试会永远停在”加载中”——这条用例在生产代码里同样成立,FutureBuilder 的 future 在 build 里现造是常见的隐性 bug,widget test 一 pump 就现形。
待办项组件的完整测试
拿《用 Flutter 做一个本地优先的待办应用》里的列表项组件当靶子——文章里靠手点验收的勾选行为,落成用例就这几行:
// lib/widgets/todo_tile.dart
class TodoTile extends StatelessWidget {
const TodoTile({super.key, required this.item, required this.onChanged});
final TodoItem item;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Checkbox(value: item.done, onChanged: (v) => onChanged(v!)),
title: Text(item.title),
);
}
}
// test/widgets/todo_tile_test.dart
void main() {
testWidgets('未完成项初始不勾选', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: TodoTile(
item: TodoItem(title: '买牛奶'),
onChanged: (_) {},
)),
));
final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
expect(checkbox.value, isFalse);
});
testWidgets('点击勾选回调携带 true', (tester) async {
bool? reported;
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: TodoTile(
item: TodoItem(title: '买牛奶'),
onChanged: (v) => reported = v,
)),
));
await tester.tap(find.byType(Checkbox));
await tester.pump();
expect(reported, isTrue);
});
}
两条用例展示两种断言姿势:读组件配置用 tester.widget<Checkbox>(...)(比靠渲染细节断言稳),验交互副作用用回调捕获。这里有个细节值得点破:回调断言其实不依赖 pump——tap 会同步触发 onChanged;pump 是给后面可能加的界面断言留位子。界面上”勾了没”归 pump 管,回调”传了没”归变量管,两层分开想清楚,用例就不会写玄。
mock:mocktail,不碰代码生成
测到依赖外部数据的组件,给个假仓库。2024 年我的选型是 mocktail:零代码生成、API 靠闭包,对比 mockito 省掉 build_runner 那套流程,写起来顺手得多。装依赖:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
mocktail: ^1.0.4
// lib/repo.dart
abstract class TodoRepository {
Future<List<TodoItem>> loadAll();
Future<void> save(TodoItem item);
}
// test/repo_test.dart
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
late MockTodoRepository repo;
setUp(() {
repo = MockTodoRepository();
});
test('loadAll 返回预置数据', () async {
when(() => repo.loadAll())
.thenAnswer((_) async => [TodoItem(title: '买牛奶')]);
final items = await repo.loadAll();
expect(items, hasLength(1));
verify(() => repo.loadAll()).called(1);
});
}
一个会咬人的细节:when/verify 里用 any() 匹配自定义类型的参数时(比如 verify(() => repo.save(any())).called(1)),mocktail 需要先 registerFallbackValue(TodoItem(...)),否则运行时直接抛 MissingStubError 类的错——第一次撞见会摸不着头脑,记一笔省十分钟。本例用的是精确调用匹配,踩不到这条。
integration test 与 CI,各一段带过
integration_test 同样是 SDK 自带,API 就是 testWidgets 那一套,只是跑在真机或模拟器上:
flutter test integration_test/app_test.dart -d <device_id>
冒烟级用途最划算:应用能启动、首页关键元素在、核心流程走通。全量回归交给上层那两层,别把 widget test 能挡的东西漏到这一层。
CI 的关键步骤,本地全绿是前置条件:
# .github/workflows/test.yml 关键步骤
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2 # action 版本以仓库当前 README 为准
with:
flutter-version: '3.22.0'
- run: flutter pub get
- run: flutter test --coverage
--coverage 会生成 coverage/lcov.info。要不要卡覆盖率阈值,我的建议是新项目先不卡,攒够第一批用例、知道正常水位后再设一条低水位线防滑坡——上来就卡 80% 的项目,最后收获的是一堆没有断言的凑数用例。
验收清单
flutter test全绿,输出形如00:01 +N: All tests passed!;- 故意把 toggle 那条断言改成
isFalse,重跑变红——确认测试真的在拦; - 删掉点击用例里的
await tester.pump(),用例变红且报 zero widgets——帧推进的理解过关; - 把 FutureBuilder 的 future 挪进 build 现造,用例卡在”加载中”——复现这个隐性 bug 并改回去;
- verify 的
called(1)改成called(2)变红——mock 验证在工作; - (可选)integration_test 在模拟器上跑通冒烟用例。
学完之后
这套样板落进《Flutter 学习路线》的位置:阶段四实战篇的验收表(飞行模式可用、杀进程数据不丢、主题记忆)每一条都能翻译成用例——”杀进程数据不丢”的正解就是给持久层写单元测试,而不是靠热重载手点(热重载保留 State,测出来的是内存不是磁盘,实战篇第六节有完整解释)。继续深入三个方向:golden 文件测试(截图比对,UI 回归利器)、integration_test 的性能面(timeline 追帧)、以及状态管理库各自的测试工具—— setState 都测熟了,换 Riverpod 时心智是平移的。
本文数据核对时间:2024-06-24:Flutter 3.22(2024-05-14 Google I/O 发布,配套 Dart 3.4,flutter_test / integration_test 随 SDK 分发);mocktail 1.0.4(pub.dev 1.0.x 系列当前版,2023 年发布);GitHub Actions 的 checkout / flutter-action 版本以各仓库当前 README 为准。API 细节以 docs.flutter.dev/testing 与 pub.dev 文档为准。