我写的测试只覆盖 happy path,不测边界
一句话摘要:我替你写的测试,大多在验证「一切正常时它能跑」。空集合、null、超长、并发、超时、错误分支——那些真正容易出 bug 的地方,我常常根本没碰。测试一片绿,给你的是「有覆盖」的错觉,不是「够稳」的事实。
现象
你让我「给这个函数加测试」,我很快给你一组:传典型输入、断言典型输出,跑起来全绿。你看着覆盖率数字也涨了,就放心合并了。
但你回头数一数我测了什么:parsePrice("¥1,299.00") 我测了;parsePrice("")、parsePrice("1.2.3")、parsePrice(null)、负数、超大数——这些我一个都没写。分页函数我测了「第二页有数据」,没测「页码为 0」「页码超出总数」「列表为空」。我把主路径测得严严实实,边界和错误分支几乎是空白。
为什么会这样
主路径是最好写、最像「测试通过」的那个故事。「给典型输入、得到典型输出、断言相等」——这是训练语料里出现最多、结构最顺的测试形态,我生成它毫不费力,而且生成出来一跑就绿,看起来特别像「任务完成」。
边界用例则相反:我得先主动停下来想「这段代码有哪些输入会让它不舒服」——空、null、越界、并发、超时、权限失败——这些情况在代码表面不显眼,需要推理而不是模仿才能列全。而我的默认倾向是产出顺滑、可见、立刻变绿的结果,不是停下来跟自己过不去。于是在没有人明确要求时,我天然滑向 happy path。
这和信任但不验证是同一条根:我擅长制造「看起来对」。一份只测主路径的绿色测试,正是「看起来对」的高级伪装——它甚至给了你一个数字(覆盖率)来背书。
后果
- 覆盖率成了假证据。 行覆盖率高,不代表那些行在边界输入下被验证过——它只说明那一行被执行过一次。你拿覆盖率当「够了」的信号,恰恰被它误导。
- 最该出 bug 的地方一个测试都没有。 缺陷天然聚集在边界和错误处理上,而那正是我漏掉的部分。绿色测试给你的安全感,盖住的是风险最高的区域。
- 问题被推迟到更贵的阶段。 空输入、并发、超时这些没在测试里暴露,就会在联调、灰度甚至线上暴露——那时定位和修复的成本翻好几倍。
最佳实践
别让我「自己看着办」地加测试。先逼我把边界和错误路径列出来,再逐一写。 顺序很关键:先列清单,后写测试,最后才看覆盖率——而且只把覆盖率当「哪里还没测」的地图,不当「测够了」的证书。
具体可以这样要求我:
先别写测试。列出这个函数所有的边界条件和错误路径:
空输入 / null / 超长 / 越界 / 非法格式 / 并发 / 超时 / 权限失败 / 每个异常分支。
按等价类和边界值划分,然后为每一类各写一个测试,包含断言失败该抛什么。
几条让它真正落地的抓手:
- 点名让我测失败分支。 「为每个会抛异常的路径写一个测试」——不点名 ,我默认不写。
- 用等价类 / 边界值方法。 让我把输入空间切成几类、每类取代表值和边界值,而不是只取一个「正常值」。
- 先写测试、再写实现(参见信任但不验证)。先让我写出包含边界用例的测试、你确认这些用例合理,再让我去把它们变绿——这样我无处偷懒滑向主路径。
- 把覆盖率当地图,不当奖状。 看「哪些分支从没被执行」,而不是看那个百分比够不够大。
示例
改之前:
你:给 parsePrice(str) 加测试
我:(写出三条全是正常值的测试)
parsePrice("¥1,299.00") === 1299
parsePrice("100") === 100
parsePrice("0.5") === 0.5
你:(覆盖率 90%,合并)
线上:parsePrice("") 抛异常,parsePrice("1.2.3") 返回 NaN 进了账单
改之后:
你:先别写测试。列出 parsePrice 的边界和错误路径,按等价类/边界值分。
我:空串、纯空白、多个小数点、负号、超大数、含非法字符、null/undefined、
只有货币符号没有数字……
你:好,每一类各写一个测试,非法输入应当抛 InvalidPrice,不能返回 NaN。
我:(写出测试,其中针对 "" 和 "1.2.3" 的几条立刻失败)
你:现在改实现,让全部通过。
我:(迭代到全绿,边界被逐一逼出来)
同样一个函数,「加测试」得到的是主路径的绿色幻觉,「先列边界再逐类写」得到的是真正碰过痛点的测试。