19 · 测试实战:单元 · Widget · 集成
目标:从"16 篇会跑一个 Widget test"到"能给业务代码写出完整测试金字塔"——单元测试 mock 外部依赖、Provider 测试、Widget 测试、集成测试与覆盖率。
💡 通用知识篇。测试四要素基础版见 16 篇 · 测试,本篇是它的完整展开。
开篇:前端概念对照
| 前端(Jest/Vitest + RTL) | Flutter 对应 |
|---|---|
describe / it | group / test(同一套 package:test 语法) |
jest.mock() | mockito 的 Mockclass extends Mock implements X |
expect(x).toBe(y) | expect(x, equals(y)) |
RTL render(<App/>) + screen.getByText | tester.pumpWidget() + find.text() |
await waitFor(...) | await tester.pumpAndSettle() |
| Playwright / Cypress E2E | integration_test |
--coverage | flutter test --coverage + lcov |
心智模型差异:RTL 查询后是"断言 DOM",Flutter 是"断言 Widget 树";RTL 用 waitFor 等异步,Flutter 用 pump 手动推时间——测试里的时间是你控制的,这是最需要转念的地方(第三节)。
一、测试金字塔与本项目的目录约定
╱ 集成测试(少数)╲ integration_test/ 完整 App 关键路径
╱ Widget 测试(适量) ╲ test/…/xx_page_test.dart 页面交互与渲染
╱ 单元测试(大量) ╲ test/…/xx_logic_test.dart 纯逻辑/Repository/Notifier- 依赖包:
flutter_test(自带)+mockito+build_runner
flutter pub add --dev mockito build_runner可测性的前提在 09 篇已经铺好:Repository 三件套让数据源可替换、Result<T> 让错误可断言。测试只是消费这些接口。
二、单元测试:mock 外部依赖
2.1 mockito 标准流程
// 设备仓库的接口(09 篇三件套的抽象层)
abstract class DeviceApi {
Future<Result<List<Device>>> fetchDevices();
}
// ① 生成 Mock 类
@GenerateMocks([DeviceApi])
import 'device_api_test.mocks.dart';fvm dart run build_runner build --delete-conflicting-outputs// ② 写测试
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late MockDeviceApi api;
setUp(() {
api = MockDeviceApi();
});
test('请求成功时,Notifier 应进入 data 态', () async {
// arrange:桩数据
when(api.fetchDevices())
.thenAnswer((_) async => Result.ok([Device(id: '1', name: '空调')]));
// act
final notifier = DeviceListNotifier(api);
await notifier.refresh();
// assert
expect(notifier.state.valueOrNull?.length, 1);
verify(api.fetchDevices()).called(1);
});
test('请求失败时,应进入 error 态且不崩溃', () async {
when(api.fetchDevices())
.thenAnswer((_) async => Result.err(ApiError('timeout')));
final notifier = DeviceListNotifier(api);
await notifier.refresh();
expect(notifier.state.hasError, isTrue);
});
}记忆钩子:when(...).thenReturn(...) 给同步值;when(...).thenAnswer((_) async => ...) 给 Future——async 必须用 thenAnswer,用错会直接返回一个 Future 对象当数据,报类型错误。
2.2 用 Result<T> 断言错误(本项目特色)
不要 try/catch 断异常,直接断 Result 分支,与业务代码同构:
final result = await repository.fetchDevices();
expect(result.isErr, isTrue);
result.when(
ok: (_) => fail('不应成功'),
err: (e) => expect(e.message, contains('timeout')),
);三、Riverpod Provider 测试:ProviderContainer
页面级测试太重、纯逻辑测试覆盖不到 Provider 组合逻辑时,用 ProviderContainer 直接测 Provider:
test('设备数量 Provider 应过滤掉离线设备', () async {
final api = MockDeviceApi();
when(api.fetchDevices()).thenAnswer((_) async => Result.ok([
Device(id: '1', name: '空调', online: true),
Device(id: '2', name: '灯', online: false),
]));
final container = ProviderContainer(
overrides: [deviceApiProvider.overrideWithValue(api)], // 关键:注入 mock
);
addTearDown(container.dispose); // 防泄漏
final count = await container.read(onlineDeviceCountProvider.future);
expect(count, 1);
});要领:不 pumpWidget、不建 BuildContext,纯状态层测试——跑得快、失败时定位准(问题必然在 Provider 逻辑而不是 Widget)。
四、Widget 测试: pump 是时间机器
4.1 与前端最大的思维差异
Flutter 测试里 UI 不会自动刷新,每个"帧"都要你手动推:
| 调用 | 含义 | 相当于前端 |
|---|---|---|
tester.pumpWidget(widget) | 首帧挂载 | render() |
tester.pump() | 推进一帧 | 触发一次微任务+重绘 |
tester.pump(duration) | 推进指定时长 | vi.advanceTimers(ms) |
tester.pumpAndSettle() | 反复推帧直到没有动画/定时器 | waitFor 全部完成 |
⚠️ pumpAndSettle 遇到无限动画(loading 转圈)会永不结束直到超时——此时改用 pump(fixedDuration)。
4.2 完整例子:列表页三态
testWidgets('加载成功后展示设备列表', (tester) async {
final api = MockDeviceApi();
when(api.fetchDevices())
.thenAnswer((_) async => Result.ok([Device(id: '1', name: '空调')]));
await tester.pumpWidget(
ProviderScope(
overrides: [deviceApiProvider.overrideWithValue(api)],
child: const MaterialApp(home: DeviceListPage()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget); // 先是 loading
await tester.pumpAndSettle(); // 推到网络返回后
expect(find.text('空调'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing); // loading 消失
});
testWidgets('点击条目跳转详情', (tester) async {
// ...同上准备数据并 pump
await tester.tap(find.text('空调'));
await tester.pumpAndSettle();
expect(find.byType(DeviceDetailPage), findsOneWidget);
});4.3 常用查找器速查
find.text('提交') // 文本
find.byType(ElevatedButton) // 类型
find.byKey(const Key('submit-btn')) // Key(推荐给关键交互节点加 key)
find.widgetWithText(ElevatedButton, '提交') // 按钮内含文本
findsOneWidget / findsNothing / findsNWidgets(3) // 数量断言项目约定:给需要测试的交互元素加
Key('模块_元素'),避免因文案改动(尤其 i18n 多语言,见 14 篇)导致测试失效。
五、集成测试:跑真实 App 的关键路径
Widget 测试 mock 了一切;集成测试跑真机/模拟器上的完整 App,用于验证"登录 → 看到首页 → 点进详情"这类主链路。
// integration_test/app_test.dart
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('登录后可见首页设备列表', (tester) async {
app.main(); // 启动真实 App
await tester.pumpAndSettle(const Duration(seconds: 3));
// 输入账号密码(用 key 定位,避开 i18n 文案)
await tester.enterText(find.byKey(const Key('login_username')), 'testuser');
await tester.enterText(find.byKey(const Key('login_password')), 'pass1234');
await tester.tap(find.byKey(const Key('login_submit')));
await tester.pumpAndSettle(const Duration(seconds: 5));
expect(find.byKey(const Key('home_device_list')), findsOneWidget);
});
}fvm flutter test integration_test # 本地模拟器
fvm flutter test integration_test -d <device-id> # 指定真机取舍:集成测试慢且脆(依赖真环境),只覆盖 1~3 条核心路径;日常回归靠单元+Widget 测试。
六、覆盖率:看数字,更要看窟窿
fvm flutter test --coverage # 生成 coverage/lcov.info
genhtml coverage/lcov.info -o coverage/html # 需安装 lcov,可视化报告经验值与原则:
| 指标 | 参考 | 说明 |
|---|---|---|
| 纯逻辑(Notifier/工具类/Repository) | ≥ 80% | 最值得测的部分 |
| Widget/页面 | 关键交互覆盖 | 不追求行数覆盖率 |
| 生成代码(.g.dart/.freezed.dart) | 排除 | 从 lcov 里过滤 *.g.dart |
警惕两个数字游戏:为 getter 写测试凑行数;只测成功路径。失败路径的测试价值高于成功路径(第二节第二个 test 就是范例)。
七、常见陷阱速查
| 陷阱 | 症状 | 解法 |
|---|---|---|
thenReturn 接 Future | 返回的是 Future 对象,类型错 | 改 thenAnswer((_) async => ...) |
loading 页用 pumpAndSettle | 测试超时(无限动画) | pump(固定时长) |
| 测试间共享单例/Mock | 顺序依赖、偶发挂 | setUp 里新建,addTearDown 清理 |
文案定位 find.text('登录') | i18n 切语言就挂 | 关键节点加 Key |
| 测了真实网络 | CI 不稳定、慢 | 永远 mock Dio/Repository 层 |
Timer 未清理 | 测试结束报 pending timer | 业务侧 dispose 取消(见 05 篇 dispose 泄漏) |
| 图片/平台通道未 mock | 报 MissingPluginException | 网络图片用 mockNetworkImagesFor,通道打桩 |
八、动手练习
练习 19.1:给 18 篇练习 8.2 的"离线优先设备列表"写测试——成功路径、失败走缓存路径、缓存也没有的空态路径,三个 test。(提示:把网络函数抽象成可注入接口)
练习 19.2:把 12 篇分页骨架的 PagedState 迁移逻辑做单元测试:加载首页、加载下一页、重复触发防抖、失败重试四个场景。
练习 19.3:为一个含输入框 + 校验 + 提交按钮的表单页写 Widget 测试:空值提交出错误提示、合法提交调用 mock 提交函数。(全部用 Key 定位)
九、自检清单
- [ ] 能说出测试金字塔三层各自的定位与数量级比例
- [ ] 知道
thenReturn与thenAnswer的区别,以及 loading 场景为什么不能用pumpAndSettle - [ ] 能用
ProviderContainer.overrides写一个不启动 UI 的 Provider 测试 - [ ] 知道本项目里
Result<T>如何让错误路径可断言 - [ ] 能配置并解读
--coverage,说出"哪些代码值得测、哪些是数字游戏"
导航:上一章 18 · 本地存储与持久化 | 下一章 20 · 异常捕获与错误监控