Skip to content

19 · 测试实战:单元 · Widget · 集成

目标:从"16 篇会跑一个 Widget test"到"能给业务代码写出完整测试金字塔"——单元测试 mock 外部依赖、Provider 测试、Widget 测试、集成测试与覆盖率。

💡 通用知识篇。测试四要素基础版见 16 篇 · 测试,本篇是它的完整展开。


开篇:前端概念对照

前端(Jest/Vitest + RTL)Flutter 对应
describe / itgroup / test(同一套 package:test 语法)
jest.mock()mockitoMockclass extends Mock implements X
expect(x).toBe(y)expect(x, equals(y))
RTL render(<App/>) + screen.getByTexttester.pumpWidget() + find.text()
await waitFor(...)await tester.pumpAndSettle()
Playwright / Cypress E2Eintegration_test
--coverageflutter 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
bash
flutter pub add --dev mockito build_runner

可测性的前提09 篇已经铺好:Repository 三件套让数据源可替换、Result<T> 让错误可断言。测试只是消费这些接口。


二、单元测试:mock 外部依赖

2.1 mockito 标准流程

dart
// 设备仓库的接口(09 篇三件套的抽象层)
abstract class DeviceApi {
  Future<Result<List<Device>>> fetchDevices();
}

// ① 生成 Mock 类
@GenerateMocks([DeviceApi])
import 'device_api_test.mocks.dart';
bash
fvm dart run build_runner build --delete-conflicting-outputs
dart
// ② 写测试
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 分支,与业务代码同构:

dart
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:

dart
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 完整例子:列表页三态

dart
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 常用查找器速查

dart
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,用于验证"登录 → 看到首页 → 点进详情"这类主链路。

dart
// 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);
  });
}
bash
fvm flutter test integration_test                      # 本地模拟器
fvm flutter test integration_test -d <device-id>       # 指定真机

取舍:集成测试慢且脆(依赖真环境),只覆盖 1~3 条核心路径;日常回归靠单元+Widget 测试。


六、覆盖率:看数字,更要看窟窿

bash
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 定位)


九、自检清单

  • [ ] 能说出测试金字塔三层各自的定位与数量级比例
  • [ ] 知道 thenReturnthenAnswer 的区别,以及 loading 场景为什么不能用 pumpAndSettle
  • [ ] 能用 ProviderContainer.overrides 写一个不启动 UI 的 Provider 测试
  • [ ] 知道本项目里 Result<T> 如何让错误路径可断言
  • [ ] 能配置并解读 --coverage,说出"哪些代码值得测、哪些是数字游戏"

导航:上一章 18 · 本地存储与持久化 | 下一章 20 · 异常捕获与错误监控