作者使用AI生成代码后覆盖率数据漂亮,但生产依然崩溃。问题在于:生成的代码(freezed、g.dart等)不应计入覆盖率,常量/主题/路由等无分支代码测了也无意义。提出了用lcov过滤排除无效文件的实践方案。
如今,我项目里大部分代码都是模型写的。这不是实验,是我们工作的常态。但这也带来了一个我以前几乎不会去想的问题:因为代码是我自己写的,所以我大概知道哪里有漏洞——但如果不是我写的,我怎么知道它有没有用?
显而易见的答案是"测覆盖率"。我确实在测。但有一次,所有测试全绿,生产页面却崩了。
这篇文章要说的就是这种矛盾,以及那些让覆盖率数字有意义或毫无意义的决策。
没什么花哨的:用 flutter test --coverage 生成 lcov.info,然后用 lcov --remove 过滤,最后再算百分比。
lcov --remove coverage/lcov.info \
'**/*.freezed.dart' \
'**/*.g.dart' \
'**/*.config.dart' \
'**/constants/*.dart' \
'**/theme/*.dart' \
'**/di/*.dart' \
'**/router/*.dart' \
-o coverage/lcov_filtered.info
过滤掉的内容及理由:
生成的代码(freezed、json_serializable、注入配置)不是我写的。测它们等于测生成器。如果 freezed 坏了,那也不是我的测试该管的事。
常量和 theme 没有分支。它们没有会根据不同输入做出不同行为的逻辑。在那里写测试只是确认一个常量等于它本身的值。
依赖注入和路由是胶水代码。验证容器能否解析一个依赖,这是在测框架。
如果非要总结一条规则,那就是:排除那些不可能做出错误决策的东西。剩下的都进来。
这里有必要先说一句可能会让人不舒服的话:排除列表就是可以作弊的地方。我可以随意移动这些模式来达到我想要的百分比。所以当数字不合我意时,我不会去动排除列表。如果我得为自己为什么排除某些东西找一个正当理由,那理由绝不能是"因为达不到"。
这是让有经验的人对覆盖率失去信任的误解,而且他们不信任是对的。
lcov 测的是哪些行被执行了,而不是你有没有验证了什么。这是两件完全不同的事,而模型生成测试时会把它们混为一谈。
test('aplica descuento', () {
final result = calculator.applyDiscount(100, 0.2);
expect(result, isA<double>());
});
这个测试执行了 applyDiscount 的每一行代码。覆盖率:100%。真正的断言:零。如果公式写反了——precio * descuento 而不是 precio * (1 - descuento)——它照样全绿。
test('aplica 20% de descuento', () {
expect(calculator.applyDiscount(100, 0.2), 80.0);
});
test('rechaza descuentos mayores al 100%', () {
expect(() => calculator.applyDiscount(100, 1.5), throwsArgumentError);
});
同样的行覆盖率。没有任何工具能测出这种差别。能测出差别的是一个问题:这个测试是在断言什么,还是只是在执行?
模型默认写的是第一种。这它们最擅长的:遍历代码,走到全绿,把数字拉上去。我审一个生成的测试时,不看百分比有没有涨,我看的是如果我故意把函数弄坏会怎样。如果测试还通过,那这个测试等于不存在。
这是让我最纠结、但也最坚持的一个决策。
我曾经跟所有人一样写 widget tests。有两件事让我停下来了。
第一件说出来有点蠢但很真实:expect 找不到 widget。有时候找得到,有时候找不到,取决于 widget 树长成什么样。最后变成跟 finder 较劲,而不是验证行为。凡是写过 Flutter widget tests 的人都懂我在说什么。
第二件才是重要的。App 里有些选项会根据用户角色显示或不显示,这种情况还挺多。但这个判断逻辑不在 widget 里——它在 BLoC 里。widget 只是把状态告诉它的事情画出来。所以如果我从 widget 端测"用户 X 看到了选项 Y",我其实是在验证一条已经在 BLoC 里验证过的规则,只是用一扇更慢、更脆弱、换个 Padding 就会坏的门。
不是说 UI 不用测。而是不测两遍同样的东西,而且我选择在测试便宜且稳定的地方测。
(确实有缺的东西,一篇诚实的文章也会说不圆满的地方:对于真正稳定的部分,golden tests 还没做。在清单上,还没动。)
从零开始的项目,我会瞄着覆盖全部逻辑。但我也在维护遗留代码,没有测试,是已经离开的人写的。
在那种代码上,我不会倒追 100%。那会花上好几个月去为不会动的代码写测试,更糟糕的是,那些测试会把当前实现冻结起来,而不是保护一种行为。
我做的事更简单:旧代码保持原样,但所有新增的部分都带着测试进来。每次动那个模块,覆盖率就往上抬一点。我不会为了覆盖过去而停止交付,而是会为了覆盖过去而停下出血。
区别在于"这个项目有 100%"和"这个项目不会再丢失覆盖率"。后者是不用让世界停下来就能实现的。
然后就到了数字当着我的面骗我的地方。
我的测试套件跑在 mock 上。整个数据层都被 mock 了,所以测试不跟服务器通信:跟一个冻结版本通信——就是上次写那个 mock 时服务器说的那样。
我们有一个按交付订单计算积分的系统。有一天,后端出了个 bug,一个已交付的订单没有加载积分,那个字段返回了 null。之前从来没有过 null。App 没扛住,Web 也没扛住,页面直接报错。
我所有测试全绿。全部。
原因说出来都丢人:我的 mock 还在返回旧世界的样子。我对一个已经不存在的现实有着完整的覆盖率。测试从来没看到过 null,因为我没写过返回 null 的 mock,而我没写是因为在我脑子里那个字段从来不会空。
这就是我对任何说"覆盖率没意义"的人的回答。你说得对,以下就是我自己的测试套件给出的证明:100% 意味着你用自己的假设执行了每一行代码。如果你的假设过期了,你测的只是你的想象力有多好。
教训不是"mock 是坏的"。我需要 mock。教训是行覆盖率和用例覆盖率不是一回事,而空值用例是我最容易漏掉的。从那以后,空值和契约变更在我测试里是一等公民,不是什么后补的想法。
test('maneja puntos nulos del backend', () {
expect(() => calculator.applyDiscount(null, 0.2), throwsArgumentError);
});
说实话,我到现在也没有一个完整的答案。写一个针对真实响应的契约测试才是正确的做法,但我现在还没有搞定。目前我的做法是假设任何字段都可能为空,即走后端拍胸脯保证不会。
覆盖率告诉你哪里没看。不告诉你你看的那些是不是对的。
模型免费送你行覆盖率,整天都能送。但它不会送你的是:决定什么值得测、哪个测试真正在断言、什么不值得覆盖、以及什么时候数字在骗你。
那些仍然是你的工作。至少现在还是。
你在覆盖率里排除了什么,为什么?我特别感兴趣的是有没有人把 API 契约和 mock 这个问题解决得比较好的,因为我还没搞定。