大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
测试
这一课你将能做到
- 写单元测试(Unit Test)验证纯 Dart 业务逻辑
- 写 Widget 测试验证 UI 组件的渲染和交互
- 用
mockito或mocktail做依赖 Mock,写集成测试(Integration Test)跑完整流程
为什么要学
Flutter 的测试金字塔:Unit 测试最多最快,Widget 测试居中,Integration 测试最少最慢但最接近真实。不写测试,改代码就是开盲盒。
人话:Unit 测试是「检查零件」,Widget 测试是「检查组装」,Integration 测试是「上路试驾」。三层配合,bug 无处藏身。
先建立图像
/\
/ \ Integration Test(少、慢、全链路)
/____\
/ \ Widget Test(中、较快、单组件)
/________\
/ \ Unit Test(多、快、纯逻辑)
/____________\
跟着做
步骤 1:添加测试依赖
pubspec.yaml:
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
integration_test:
sdk: flutter
步骤 2:单元测试
// lib/calculator.dart
class Calculator {
int add(int a, int b) => a + b;
int divide(int a, int b) {
if (b == 0) throw ArgumentError('除数不能为 0');
return a ~/ b;
}
}
// test/calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/calculator.dart';
void main() {
group('Calculator', () {
final calc = Calculator();
test('add 正常', () {
expect(calc.add(2, 3), 5);
});
test('divide 正常', () {
expect(calc.divide(10, 2), 5);
});
test('divide 除 0 抛异常', () {
expect(() => calc.divide(1, 0), throwsArgumentError);
});
});
}
运行:
flutter test
步骤 3:Widget 测试
// 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: Text('$_count')),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _count++),
child: const Icon(Icons.add),
),
);
}
}
// test/counter_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/counter_page.dart';
void main() {
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(); // 触发 rebuild
expect(find.text('1'), findsOneWidget);
});
}
关键 API:
tester.pumpWidget(widget):渲染 Widgettester.tap(finder):模拟点击tester.pump():触发一帧 rebuildtester.pumpAndSettle():等所有动画结束find.text('x')/find.byIcon(...)/find.byKey(...):查找 Widget
步骤 4:Mock 网络请求
// lib/todo_repository.dart
abstract class TodoRepository {
Future<List<String>> fetchTodos();
}
// test/todo_page_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/todo_repository.dart';
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
testWidgets('加载待办列表', (tester) async {
final repo = MockTodoRepository();
when(() => repo.fetchTodos()).thenAnswer((_) async => ['买牛奶', '写代码']);
await tester.pumpWidget(MaterialApp(home: TodoPage(repository: repo)));
await tester.pumpAndSettle();
expect(find.text('买牛奶'), findsOneWidget);
expect(find.text('写代码'), findsOneWidget);
});
}
mocktail 核心:when(() => mock.method()).thenAnswer(...),验证用 verify(() => mock.method()).called(1)。
步骤 5:集成测试
// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('完整流程:登录 → 添加待办', (tester) async {
app.main();
await tester.pumpAndSettle();
// 输入邮箱密码
await tester.enterText(find.byKey(const Key('email')), 'a@b.com');
await tester.enterText(find.byKey(const Key('password')), '123456');
await tester.tap(find.text('登录'));
await tester.pumpAndSettle();
// 添加待办
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('title')), '测试任务');
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.text('测试任务'), findsOneWidget);
});
}
运行:
flutter test integration_test
集成测试跑在真机/模拟器上,速度很慢(分钟级),只覆盖核心链路。
步骤 6:Golden 测试(视觉回归)
testWidgets('CounterPage 视觉一致', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
await expectLater(
find.byType(CounterPage),
matchesGoldenFile('counter_page.png'),
);
});
第一次运行生成基准图:
flutter test --update-goldens
之后每次跑测试都会对比像素差异,防止 UI 被意外改坏。
讲清楚要点
- 测试金字塔:Unit(多快)→ Widget(中)→ Integration(少慢),比例约 70/20/10。
- Widget 测试不跑真机:在内存里渲染,速度接近 Unit 测试,能验证布局、交互、状态。
- Mock 是为了隔离依赖:网络、数据库、平台通道都应该 Mock,保证测试确定性。
- Integration 测试覆盖「核心用户路径」:注册→登录→核心功能→退出,不要追求 100% 覆盖。
- Golden 测试防 UI 回归:适合设计系统组件,不适合频繁变动的业务页面。
你可能会懵的地方
-
tester.pump()和pumpAndSettle()区别?pump()只触发一帧;pumpAndSettle()循环触发直到没有动画/定时器,等 UI 完全稳定。 -
Widget 测试能跑网络请求吗? 能,但不应该。网络请求应该 Mock,否则测试慢且不稳定。
-
mockito 和 mocktail 选哪个? mocktail 不需要代码生成,API 更简洁;mockito 需要
build_runner生成 mock 类。新项目推荐 mocktail。 -
为什么
find.text('0')找不到? 文本可能不在 Text Widget 里(比如 RichText);或者 Widget 还没渲染(需要pump());或者文本被拆成多个 TextSpan。 -
集成测试怎么调试? 加
--dart-define传环境变量;用tester.takeScreenshot()截图;在关键步骤加debugPrint。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
Widget 测试里写 await Future.delayed(...) | 测试慢且不稳定 | 用 tester.pump(Duration(...)) 快进时间 |
| 不 Mock 直接发真实网络请求 | 测试慢、依赖网络 | 用 mocktail Mock Repository |
expect(find.text('x'), findsOneWidget) 前没 pump() | Widget 还没 rebuild | tap 之后必须 pump() 或 pumpAndSettle() |
| Golden 测试在 CI 上跑 | 不同设备渲染像素不同 | CI 上禁用 Golden,或固定设备/字体 |
| 集成测试里测边界条件 | 慢且难维护 | 边界条件放 Unit/Widget 测试,集成测试只跑主流程 |
卡点急救
pumpWidget报No MediaQuery widget found:顶层没包MaterialApp,加上即可。find.byKey找不到:确认 Key 类型一致(Key('x')vsValueKey('x'));确认 Widget 已渲染(pump())。- Mock 不生效:
when的参数必须和实际调用完全匹配;用any()匹配任意参数。 - Golden 测试本地过 CI 挂:字体渲染差异。在
flutter_test_config.dart里加载固定字体。 - 集成测试报
Target of URI doesn't exist:integration_test包路径写错,确认dev_dependencies里有integration_test: sdk: flutter。
面试常问
-
Flutter 测试分几层?各自特点? Unit:纯 Dart,最快,不依赖 Flutter 引擎;Widget:内存渲染,验证 UI 和交互;Integration:真机/模拟器,最慢但最真实。
-
Widget 测试的原理?
testWidgets创建一个WidgetTester,内部用TestWidgetsFlutterBinding在内存里构建 Widget 树并渲染,不经过 Skia 绘制到屏幕。 -
怎么测异步代码? Unit 测试直接
await;Widget 测试用tester.pumpAndSettle()等所有 Future 完成;Integration 测试同样用pumpAndSettle。 -
什么是 Golden 测试? 把 Widget 渲染成图片,和基准图做像素对比。用于设计系统组件的视觉回归测试。
自测三问
- 写一个 Widget 测试:验证点击按钮后文本从 "0" 变成 "1"。
- mocktail 的
when和verify分别干什么? - 为什么 Integration 测试不应该追求高覆盖率?
小结一句话
Flutter 测试 = Unit(快)+ Widget(中)+ Integration(慢);Mock 隔离依赖,Golden 防 UI 回归,三层配合保质量。
延伸
- 官方测试文档:<https://docs.flutter.dev/testing>
- mocktail 文档:<https://pub.dev/packages/mocktail>
- 下一课:CI/CD 与发布,把测试跑在云端,自动打包上架。