测试:单元、Widget 与集成测试
测试在 Flutter 里的性价比很高:纯逻辑测试毫秒级跑完,Widget 测试不用真机就能验证交互,只有跨端到端的部分才需要模拟器。真正让人放弃写测试的原因通常只有两个——不知道怎么 mock 依赖,以及不知道 pump 和 pumpAndSettle 该用哪个。本章按测试金字塔自下而上讲,每个层级都给出可直接抄的模板。
测试金字塔
| 层级 | 依赖的包 | 运行环境 | 覆盖目标 | 数量占比 |
|---|---|---|---|---|
| 单元测试 | test、mocktail | Dart VM,无 UI | 仓储、格式化、状态机的纯逻辑 | 最多 |
| Widget 测试 | flutter_test | 无头引擎,不渲染真像素 | 页面交互、状态变化、异常态 | 中等 |
| 集成测试 | integration_test | 真机或模拟器 | 关键链路端到端 | 只留最关键的几条 |
判断标准:一段逻辑如果不需要看界面就能断言对错,它就应该被单元测试覆盖;界面上的“点了按钮会怎样”交给 Widget 测试;只有跨越真实网络、平台插件、路由跳转的链路才值得上集成测试。
单元测试:手写 fake 往往比 mock 好用
// test/cart_repository_test.dart
class FakeCartApi implements CartApi {
@override
Future<List<Item>> fetch() async => const [Item('a', 10), Item('b', 20)]; // 固定数据,结果可预测
}
void main() {
group('CartRepository', () {
test('汇总金额时忽略已下架商品', () async {
final repo = CartRepository(api: FakeCartApi());
expect(await repo.totalPrice(), 30); // 断言精确值,不要只断言不为 null
});
test('接口报错时抛出业务异常', () async {
final repo = CartRepository(api: FailingApi());
await expectLater(repo.totalPrice(), throwsA(isA<BizException>()));
});
});
}
接口只有一两个方法时,手写 fake 比引入 mocktail 更直观;依赖多、需要验证调用次数时再用 mocktail 的 when/verify(它不需要生成代码,比 mockito 少一步 build_runner)。
Widget 测试:testWidgets
// test/widget/product_list_test.dart
void main() {
testWidgets('列表加载完成后展示商品', (tester) async {
await tester.pumpWidget(const MaterialApp(home: ProductListPage(fake: true)));
expect(find.byType(CircularProgressIndicator), findsOneWidget); // 首帧应为加载态
await tester.pumpAndSettle(); // 等 Future 与动画都结束
expect(find.text('商品 A'), findsOneWidget);
expect(find.byType(ProductCard), findsNWidgets(2)); // 断言结构而不是只断言文字
});
testWidgets('搜索框输入后过滤列表', (tester) async {
await tester.pumpWidget(const MaterialApp(home: SearchPage()));
await tester.enterText(find.byKey(const Key('searchField')), '耳机');
await tester.pump(); // 只有一帧 rebuild,没有动画,用 pump 即可
expect(find.text('蓝牙耳机'), findsOneWidget);
expect(find.text('蓝牙音箱'), findsNothing);
});
}
查找器优先顺序:find.byKey(最稳,不随文案变化)> find.byType > find.text(文案一改测试就碎)。给关键控件加 Key 是最低成本的测试友好改造。
| 方法 | 行为 | 何时用 |
|---|---|---|
pump() | 触发一帧重建 | 改状态后的即时断言、精确控制帧数 |
pump(Duration) | 推进指定时间 | 定时器、AnimatedContainer 的中间态 |
pumpAndSettle() | 循环 pump 直到没有待处理帧 | 页面跳转、动画结束后的断言 |
pumpWidget() | 挂载整棵测试树 | 每个测试的起点,内部会先 pump 一次 |
pumpAndSettle 遇到永不停止的动画(如循环的 CircularProgressIndicator 配合无限动画)会超时,此时应改用 pump(const Duration(milliseconds: 300))。
mock 网络与平台通道
void main() {
TestWidgetsFlutterBinding.ensureInitialized(); // 用通道 mock 必须先初始化绑定
const channel = MethodChannel('com.example.app/device');
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getBatteryLevel') return 88; // 假数据,测试不依赖真机
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null); // 不清理会影响其他测试
});
test('batteryLevel 正确解析原生返回值', () async {
expect(await DeviceChannel.batteryLevel(), 88);
});
}
网络层建议把 http.Client 或 Dio 实例通过构造函数注入,测试时传入假实现;如果代码里直接用了 HttpClient,可以用 HttpOverrides.runZoned 拦截,但那属于补救手段。图片测试常因网络加载失败报错,用 mockNetworkImagesFor 或注入本地 asset 更稳。
集成测试与覆盖率
// integration_test/app_test.dart
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // 必须先调,否则收不到真实事件
testWidgets('端到端:从列表进入详情', (tester) async {
app.main(); // 启动真实入口,跑的是生产代码路径
await tester.pumpAndSettle();
await tester.tap(find.text('商品 A'));
await tester.pumpAndSettle();
expect(find.text('加入购物车'), findsOneWidget);
});
}
flutter test # 单元测试 + Widget 测试
flutter test --coverage # 生成 coverage/lcov.info
genhtml coverage/lcov.info -o coverage/html # 需要 lcov 工具,产出 HTML 报告
flutter test integration_test/app_test.dart -d emulator-5554 # 指定设备跑集成测试
flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart # 旧版驱动方式
CI 里跑测试的注意点:Golden 测试依赖字体渲染,Linux 容器与 macOS 上像素可能有细微差异,建议固定 CI 镜像并把 Golden 测试限制在少数关键页面上;集成测试需要模拟器或真机,通常放在夜间任务而不是每次提交;flutter test 默认并行执行,共享全局状态的测试要用 setUp/tearDown 清理干净。
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
忘了 await tester.pump() | 断言的是旧界面,测试假通过 | 改状态后必须 pump 再断言 |
无脑用 pumpAndSettle | 有循环动画时超时失败 | 改用定长 pump(Duration) |
只用 find.text | 文案一改测试就挂 | 关键控件加 Key,用 find.byKey |
| 通道 mock 不清理 | 影响后续测试,问题难定位 | tearDown 里把 handler 置回 null |
| 假数据依赖真实网络 | 测试随机失败 | 注入 fake 客户端,测试内零网络 |
小结:测试投入按金字塔分配,纯逻辑用 test 加手写 fake、界面交互用 testWidgets 配 find.byKey、关键链路用 integration_test;pump 用于精确控制帧、pumpAndSettle 只在动画会停时使用,通道与网络一律在测试里 mock 掉,覆盖率用 flutter test --coverage 生成并在 CI 中回归。