6.3 测试三层防线:单元、Widget 与集成


6.3 测试三层防线:单元、Widget 与集成

本节摘要:性能关过了,还要过正确性关。本节架起三层测试:单元测试毫秒级验证仓库与控制器的逻辑,Widget 测试在真机上不了场的情况下验证界面行为,集成测试模拟真实用户跑通关键路径。三层各拦什么雷、各花多少成本、怎么接进持续集成——读完你将拥有一条提交即验证的自动防线。

三层的分工:越靠下越便宜

测试金字塔的道理在这里同样成立,而且 Flutter 把三层都做进了官方工具链:越靠下的测试跑得越快、越稳定、定位越准;越靠上越接近真实、越慢也越脆。分工 accordingly:

单元测试管逻辑:仓库的读写、控制器的状态迁移、模型层的翻译(第 5.2 节那三个用例就是它)、同步用例的幂等性。不碰界面、不碰真机,毫秒级一个。

Widget 测试管界面行为:点了记账按钮,面板是否弹出;输入金额提交,列表是否多出一行。它在测试环境里把 Widget 渲染成结构树,模拟点按与输入,断言界面上"有没有、变没变"。单条秒级。

集成测试管关键路径:真机或模拟器上,模拟用户从打开应用、记一笔账、切统计页、杀进程重启验证数据还在——完整走一遍。慢,所以只放最不能出错的几条路。

图 16 测试金字塔与各层的职责边界

图 16 测试金字塔与各层的职责边界

第一层:单元测试,仓库与控制器

第 4 章仓库模式的红利此刻兑现:内存实现一注入,业务逻辑的测试就不需要任何设备。同步用例的幂等验证是最能体现架构价值的一个:

import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('同步幂等:跑两遍结果与跑一遍一致', () async { final local = DbBillRepository(inMemory: true); final remote = FakeRemoteServer(); // 假服务端:内存里记账 await local.save(demoBill(synced: false)); final useCase = SyncUseCase(local, remote); await useCase.run(); await useCase.run(); // 第二遍不该产生重复或错乱 expect(remote.upsertedIds, [demoBillId]); // 只上报过一次 expect((await local.pendingSync()).length, 0); // 队列清空 }); test('冲突和解:删除赢过修改', () async { final repo = DbBillRepository(inMemory: true); await repo.save(demoBill()); // 本地已有 await repo.softDelete(demoBillId); // 本地已删 await repo.mergeRemote([freshBill(updatedAtLater: true)]); // 远端改过 final all = await repo.loadAll(); expect(all.where((b) => b.id == demoBillId), isEmpty); // 仍是删除态 }); }

两个测试共同的关键是假实现:FakeRemoteServer 在内存里模拟服务端行为,毫秒级返回、行为可预测。写不出单元测试的逻辑,多数时候不是测试技巧问题,而是依赖没隔离——那要先回去修架构(仓库、注入、纯函数),测试只是把架构质量显影出来。

第二层:Widget 测试,界面行为

Widget 测试的机制是把界面渲染进测试环境,用 tester 模拟交互并断言界面结构。轻记账"记一笔"的完整闭环测试:

testWidgets('记账面板:输入金额提交后列表多出一行', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [billRepositoryProvider.overrideWithValue(MemoryBillRepository(seed: []))], child: const MaterialApp(home: TodayPage()), ), ); await tester.tap(find.byIcon(Icons.add)); // 打开面板 await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), '42.5'); await tester.tap(find.text('记下这笔')); await tester.pumpAndSettle(); // 等动画与状态稳定 expect(find.text('42.5'), findsOneWidget); // 列表出现了这笔账 expect(find.byType(BillTile), findsOneWidget); });

pumpAndSettle 是 Widget 测试的灵魂搭档:它等所有动画与帧调度结束再断言——没有它,断言跑在动画中途,结果时灵时不灵。测试里注入的内存仓库让这条用例不依赖任何真实存储,干净、快、可重复。交互细节(下拉刷新、空态文案、错误提示条)都可以用同样的模式铺开,Widget 测试的性价比在"回归防护"上最高:改版改坏的交互,提交那一刻就被点名。

第三层:集成测试与流水线

集成测试跑在真机上,写法是"应用本身加一个驱动器"——integration_test 包让测试代码直接驱动真实应用:

void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('关键路径:记账到重启不丢', (tester) async { app.main(); await tester.pumpAndSettle(const Duration(seconds: 2)); await tester.tap(find.byIcon(Icons.add)); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), '99'); await tester.tap(find.text('记下这笔')); await tester.pumpAndSettle(); // 模拟杀进程重启:重建应用,数据应还在 await tester.binding.deferFirstFrame(); app.main(); await tester.pumpAndSettle(const Duration(seconds: 2)); expect(find.text('99'), findsOneWidget); }); }

三条流水线命令对应三层,接入持续集成只需把它们按序排进提交钩子:

$ flutter analyze # 静态检查:最便宜的防线,先跑 $ flutter test # 单元与 Widget:秒到分钟级 $ flutter test integration_test -d <设备> # 集成:出包前或夜间跑

防线的心法是成本递增、数量递减:静态检查每次提交都跑,单元与 Widget 测试每次提交都跑,集成测试出包前跑。轻记账接入流水线后经历过的真实一幕:一次依赖升级改了日期格式化的行为,三个单元测试当场变红——问题在人肉回归根本不会碰到的角落里被拦在合并之前。防线的价值不在于写得漂亮,在于总在你看不见的地方接住一次坠落

本节要点回顾

  • 三层分工:单元测逻辑、Widget 测交互、集成测关键路径,成本递增数量递减;
  • 单元测试的门槛是依赖隔离,写不出测试先怀疑架构(仓库与注入);
  • pumpAndSettle 等动画结束再断言,Widget 测试稳定性的第一保障;
  • 集成测试聚焦"不能出错"的少数路径,记账到重启不丢是标配用例;
  • 流水线三层按成本排序接入,静态检查最便宜先跑,集成出包前跑。

质量关通过,轻记账已是内壮外实。最后一章处理"出圈":平台通道、嵌入原生应用、Web 与桌面的差异,以及把这一切打包上架。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U