
1. 一次“全绿”却翻车的事故代码是怎么骗过我的先说一件我踩过的真事。几年前我负责一个订单状态流转模块改动了一个内部方法把原来同步的状态更新改成了带缓冲的异步写入。单测跑了一遍全绿覆盖率 87%CI 直接放行。上线当天就出了事故——用户看到订单状态没有变化而后台数据库里字段其实已经改了。后来定位发现问题就出在“测试通过了”这件事本身我的单测里根本没有等待异步写入完成就立刻断言断言读到的永远是旧值。测试报告告诉我“没问题”但代码在真实环境里撒了个大谎。这件事给了一个很深刻的教训测试工程的本质不是证明代码“在工作”而是识别代码“在什么时候会骗你”。大多数测试写不好不是因为不会写而是因为默认代码是诚实的——默认它按注释说的做、按你记忆中的逻辑做、按正常路径做。但代码恰恰是最会撒谎的东西边界条件会撒谎、并发逻辑会撒谎、被 mock 掉的外部依赖会撒谎甚至测试代码自己也会撒谎。这篇内容我围绕“会撒谎的代码”展开聊聊为这类代码写测试时真正需要面对的问题假象从哪来、测试怎么设计才不会轻易被骗、以及如何识别测试体系里那些“自欺欺人”的环节。适合刚把测试当成正式工程实践的开发者也适合正在为存量系统补测试的质量或后端工程师参考。2. 三类最常见的“谎言”边界、状态与假依赖想拆穿代码的谎言得先搞明白代码最喜欢在哪些地方说谎。根据我这些年跟测试死磕的经验绝大多数“代码骗过了测试”的情况都能归到下面三类里。2.1 边界条件说谎条件判断比你以为的更早或更晚代码最经典的谎言就是“差一点”。举个例子你写了一个分页函数public ListItem getPage(ListItem all, int page, int size) { int fromIndex (page - 1) * size; int toIndex Math.min(fromIndex size, all.size()); return all.subList(fromIndex, toIndex); }如果你只测了 page1、page2、page3 这种正常值测试大概率是全绿的。真正骗你的是 page0、page-1或者 size0。page0 时 fromIndex 是 -1subList 直接抛 IndexOutOfBoundsExceptionsize0 时 fromIndex 和 toIndex 相等返回空列表——这也许是对的也许不是取决于产品定义。但你的测试没测这些所以代码可以在“你没看过的地方”自由撒谎。边界条件是说谎重灾区因为它们只出现在特定输入组合下。你写测试时脑子里默认“参数都是合理的”但代码从不管你是不是合理它只会机械执行。2.2 状态说谎同一个方法不同状态下行为完全不同更隐蔽的谎言是“状态相关”。同一个函数在用户已登录和未登录时行为不同在缓存命中与未命中时行为不同在第一次调用和第二次调用时行为不同。如果你测试时没有显式构造状态代码会用“它自己默认的状态”骗你。我见过一个很典型的例子。一个支付模块的isAllowedToRefund()方法内部依赖一个内存缓存判断用户是否在黑名单里。测试里这个方法返回 true因为缓存是空的但生产环境黑名单缓存里恰好有数据同一段代码在线上行为完全反转。代码没有变变的是状态但测试报告照样显示“通过”。测试工程师要永远记住一件事代码的诚实性依赖于前置状态而不是代码本身。你测试的不是一段孤立逻辑而是一个“在特定状态下的逻辑”状态的假设没写在测试里就意味着假象。2.3 假依赖说谎mock 出来的世界从来不会像真实世界那样捣乱第三种谎言来自依赖。测试里我们经常 mock 掉外部服务、数据库、消息队列这是好事能隔离不确定性。但 mock 也会撒谎——它太听话了。真实的外部依赖会超时、会返回乱序数据、会在重试时恰好重复提交、会在网络抖动时部分失败。而你 mock 的依赖永远是“正常返回一个你写死的值”。所以你会看到这样的现象单测全绿一接真实环境立刻挂因为真实依赖参与了测试才暴露问题。我给个具体例子。你 mock 了一个支付网关的响应固定返回 SUCCESS然后测自己的回调逻辑测试通过。但真实网关可能在 5 秒后回调、可能回调两次、可能在返回 SUCCESS 之后又发一个 REVERSED。这些行为你的 mock 全部假装没发生——这就是假依赖在替你撒谎。3. 拆穿谎言的三板斧边界钻孔、状态穷举和契约立据知道了代码在哪几个方向撒谎接下来就要用工程手段拆穿它。我的经验里有三套方法对“拆谎”特别有效能覆盖掉前面三类问题的大头但每套方法都有前提和适用边界下面详细展开。3.1 边界钻孔把“差一点”的地方全部炸开第一板斧是专门针对边界条件的。思路很简单不要等 bug 出现在边界上而是主动把边界周围的所有值都钻一遍。以刚才的分页函数为例写测试的时候不要只写 page1、2、3。你要钻井盖一样把page-1、page0、page1、page最大值、size0、size1、size负数、fromIndex size 恰好等于 all.size()、fromIndex size 恰好溢出 int这些情况全测一遍。具体做法是把这个方法当成一个“边界函数”来分析。凡是存在-1、0、1、最大值、最小值、size-1、size1这类关键值的地方全部作为独立测试用例写进去。不要只写正常路径然后把边界值顺手带上要让每个边界值都成为一个有名字、有断言的测试用例比如Test void pageZero_shouldReturnFirstPage() { // page0 时页面语义通常期望返回第一页 ListItem result getPage(items, 0, 10); assertEquals(expected, result); } Test void negativePage_shouldThrowOrReturnEmpty() { // 负页码明确选择一种行为并固化下来 assertThrows(IllegalArgumentException.class, () - getPage(items, -1, 10)); }这样做的价值有二一是边界异常发生时测试名字直接告诉你“哪种边界出了事”二是把行为决策固化下来后面人改代码时跑一遍测试就能看出原来的意图。这里我要强调一个关键点边界钻孔不是把输入值“凑一遍”而是把行为分支“逼出来”。一个条件判断if (a 10)真正要测的是 a10 和 a11 之间行为是否如预期切换而 10 和 11 就是边界。做一轮“分支覆盖分析”把代码里所有、、、、equals、null判断都找出来然后每个判断的临界点写透这一步做完大多数边界谎言基本无处遁形。3.2 状态穷举预设矩阵一次性覆盖前置状态第二板斧针对状态说谎。核心思路是写测试前先列出这个功能可能处于哪些前置状态然后把状态矩阵当成测试用例来设计而不是只测默认状态。我习惯用一个非常土但非常灵的方法——“状态前置检查表”。针对每个待测方法你先回答这几个问题这个方法依赖哪些内部状态比如缓存、会话、配置开关、数据库记录这些状态各自有哪些取值特别是空、非空、过期、损坏、并发修改等异常取值。状态之间是否存在组合效果比如“用户是管理员”和“用户所在的租户已欠费”组合起来行为会不会变拿前面的isAllowedToRefund()举例正确的测试设计应该是这样的前置状态期望行为黑名单缓存为空允许退款黑名单缓存包含当前用户拒绝退款黑名单缓存过期回源数据库查询并返回正确结果缓存服务异常走降级策略返回允许退款或拒绝按业务定把这 4 种情况各写一个独立的测试用例本质上你就是在对“状态的谎言”做穷举。测试不只是在测方法逻辑更是在把方法“在什么状态下会说什么话”全部记录在案。如果状态是组合式的比如用户角色 租户状态 支付渠道就做成一个测试矩阵代码虽然多一点但暴雷的概率会断崖式下降。3.3 契约立据mock 可以假但假要有边界第三板斧用于处理假依赖的谎言。我完全支持用 mock 做隔离但有一条红线mock 越多的外部行为你越需要用一个真实的行为描述来约束它。具体做法分三步走。第一步mock 外部依赖之前先找一份“依赖的真实行为说明书”——接口文档、真实调用日志、或者抓包记录都可以。你要知道真实支付网关会在什么情况下返回什么字段而不是想当然地 mock 一个永不超时、永不错乱、永不重复的完美依赖。第二步在 mock 里模拟“坏行为”至少模拟三样延迟、异常、异常数据。我在代码里见过的诚实测试mock 通常会这样写when(paymentGateway.charge(any())) .thenReturn(successResponse()) .thenThrow(new TimeoutException()) .thenReturn(failResponse(INSUFFICIENT_FUNDS));同一个 mock第一次调用返回成功第二次调用抛超时第三次调用返回余额不足。这样你的被测代码就被迫面对“依赖也会不听话”的现实而不是活在一个依赖永远听话的幻想里。第三步对跨系统的关键交互加入契约测试。契约测试的思路是把消费方对依赖的期望“固化”成一个可校验的测试集依赖方改动时要跑这套测试来验证“你没有打破我的假设”。很多团队看不起契约测试觉得它只是把 mock 搬到了另一个仓库但真正经历过“上游悄悄改字段类型导致下游线上爆炸”的人会明白契约测试是唯一能在发布前拦住这种假依赖谎言的闸门。4. 别让测试自己撒谎假绿、脆测和覆盖率的幻觉前面在拆穿代码的谎言但实际上测试工程里更常见的、也更坑的是测试代码自己在撒谎。一套看起来健康、全绿、高覆盖率的测试可能每天都在输出错误的安全感比没有测试还危险。这一节专门聊测试体系自身的“谎言”长什么样以及怎么识别。4.1 测试写满了断言但断言什么都没验证这是我见过最普遍的“测试谎言”测试方法里代码一大坨断言写了一堆但仔细看每个断言都在重复被测代码的内部实现而不是验证真实行为。典型的例子是测一个排序功能测试这么写ListInteger result sorter.sort(new ArrayList(Arrays.asList(3, 1, 2))); assertEquals(1, result.get(0)); assertEquals(2, result.get(1)); assertEquals(3, result.get(2));这个测试没问题。有问题的版本是这样的ListInteger input new ArrayList(Arrays.asList(3, 1, 2)); ListInteger result sorter.sort(input); assertEquals(input.size(), result.size()); assertEquals(true, result.contains(3)); assertEquals(true, result.contains(2)); assertEquals(true, result.contains(1));这个测试同样全绿但它完全验证不了排序的正确性——一个不管输入多大都原样返回的“排序”函数也能让这个测试通过。这就是断言写在了“不相关的地方”没验证顺序只验证了“元素还在”。要拆穿这种谎言唯一的办法是断言必须对着真实行为写。排序就验证顺序缓存就验证命中率支付就验证金额和幂等性不要用一堆“非核心属性”的断言凑数。我给自己定过一个规矩一个测试用例如果删掉所有断言被测代码的行为仍然能被“验证”那这个测试就是在骗你。4.2 脆测测试先于需求变化的“狼来了”另一种测试谎言是脆测。表现为代码行为完全没变但测试频繁变红——原因是测试过度耦合了实现细节。举一个最常见的场景。被测代码内部调用了某个对象的init()方法测试里你用 Mockito 写了verify(dependency).init();然后某天init()改名为initialize()功能完全没变但你的测试红了。这个红是“假红”因为真实行为没有任何破坏。类似的情况还包括断言了调用次数实际只是时序巧合、断言了内部私有方法的调用、断言了 log 输出格式。脆测带来的最大危害不是“多花时间改测试”而是它会消磨团队对测试的信任。测试红了开发看一眼噢又是那个脆测ignore。时间一长真正有价值的失败也会被当成“又是那个脆测”无视掉——这就是谎言的最高形态测试输出了假警报而真警报被当成假警报处理了。应对脆测我的经验是写测试时先问“如果我想重构内部实现但不改变任何外部行为这个测试会不会红”如果答案是“会”那这个测试就绑定了实现细节建议调整断言目标让断言对准行为而非过程。存储换成 Redis 还是本地内存测试都不该感知感知了就是你被实现细节骗了。4.3 覆盖率的数字魔术90% 的覆盖率一半的代码没测过覆盖率是最容易被误读的测试指标。团队报喜常常用“覆盖率 90%”但这个数字背后可以隐藏非常多的谎言。举一个例子。一个函数有 10 行代码其中 9 行是简单的 getter 和空对象检查第 10 行才是核心算法。一行核心算法 九行流水代码如果你只测了流水代码覆盖率也能算到 90%。再换一个例子一个函数有 20 条分支你的测试只覆盖了 6 条分支但行覆盖率达到 85%——业务逻辑里最关键的 14 条分支全是黑盒但你报给领导的覆盖率依然漂亮。覆盖率这个指标不是不能用但你要清楚地意识到行覆盖率衡量的是“哪些行被执行过”完全不代表“哪些行为被验证过”。我自己看覆盖率从来不看百分比只看两样东西分支覆盖率趋势以及未覆盖分支清单。未覆盖的分支清单比总百分比有价值一百倍——它直接告诉你代码在哪些地方还有机会撒谎。所以测试工程里真正该追的不是“覆盖率上 90%”而是“把覆盖率报告里的未覆盖分支一条条清掉并且说清楚每一条为什么能清”。这个工作量比单纯堆测试大但它逼着你去面对代码里那些尚未被验证过的角落而不是睡在一张全绿的高覆盖率报表上。5. 与“会撒谎的代码”正面对抗一个真实项目的测试改造前面讲的都是思路和方法这一节我拿一个真实做过的项目来演示“拆谎”的完整过程操作路径你可以直接照搬。项目是一个内部工单系统核心模块是“工单自动分配”根据用户的等级、地区、当前排队数决定工单分给哪个客服。这个模块的代码“撒谎”得很典型正好用来做案例。5.1 现状盘点先找出代码可能在哪些地方骗我们接手时这个模块的测试覆盖率显示 85%但线上投诉不断——工单分给了错误的客服。我做的第一件事不是补测试而是带着前面说的“三类谎言”框架去做代码走查找出的问题如下边界谎言分配算法里有一个if (queueSize / weight 5)的判断但 weight 在某个配置分支下可能为 0直接导致除零异常而现有测试全是用默认 weight 跑的根本碰不到这个分支。状态谎言分配结果依赖内存中的一个“客服当前负载”缓存缓存未初始化时所有客服负载都是 0算法会均匀分配但生产环境缓存初始化之后部分客服负载显示很高算法直接把工单全给了低负载客服出现严重的分配倾斜。而测试里缓存永远是有值的而且是固定值所以测不出倾斜。假依赖谎言测试里 mock 了“用户等级查询接口”每个用户都返回“黄金会员”但真实接口对未登录用户返回的等级是 null算法对 null 处理有问题直接抛 NPE。mock 世界里的用户全是好人真实世界里总有人不按你的文档填数据。这三类问题全部躲过了 85% 覆盖率的测试网如果不是带着“找谎言”的思路去查靠覆盖率报告根本不可能发现。5.2 破局设计为每个“谎言”写一份对抗测试定位完问题我组织了三个维度的“对抗测试”跟现有测试互补。第一维是边界对抗。针对 weight 为 0 的场景我专门写了一个测试用例用TestassertThrows把这个行为固定下来——产品确认 weight0 时应该降级为默认权重而不是抛异常所以修完代码之后这个测试就成了保护伞再有人改成直接除测试立刻红。第二维是状态对抗。我写了一个“缓存未初始化”的测试Test void whenLoadCacheEmpty_shouldFallbackToDatabaseWeights() { when(loadCache.fetchAll()).thenReturn(emptyMap()); ListAssignment result distributor.assign(ticket, agents); // 期望结果走数据库权重分配均衡 assertTrue(result.stream().allMatch(a - a.weight() 0)); }同时我还加了一个“缓存数据偏斜”的测试模拟一个客服负载比其他客服高 10 倍的情况断言算法必须考虑这个偏斜不能平均分配。这一步相当于在测试层面对“状态”做了穷举。第三维是假依赖对抗。我把用户等级接口的 mock 改成一组“坏数据”when(userService.fetchLevel(any())) .thenReturn(GOLD) .thenReturn(null) .thenReturn(SILVER);一个 mock 依次返回正常、异常、另一种正常。这样被测代码就必须对 null 等级做处理而不是侥幸地假设永远不会来 null。这就是把 mock 从“完美依赖”改成“会捣乱的依赖”。5.3 重构落地让之前撒谎的代码“被迫诚实”测试写好后代码自然是红的。然后我逐个修。weight 为 0 的场景在配置加载时加校验默认 weight1缓存未初始化时增加降级逻辑直接回源数据库读取权重再进缓存用户等级为 null 时默认按普通会员处理。这个过程中我反复体会到一个现象好的测试能逼着代码变诚实。不需要测试工程师苦口婆心地劝开发“你这里加个判空吧”测试红在那里代码不修就发布不了所有人都主动把边角补上了。这和“测试只是验证一下有没有 bug”是完全不同的思路——测试不是在检查代码而是在定义代码必须遵守的契约。改造完成后同一套模块的覆盖率还是 85% 左右但线上投诉基本清零了。区别不在于覆盖率的数字而在于覆盖的“行为空间”彻底变了那些曾经能自由撒谎的边界、状态、依赖缝隙全部被测试钉死了。5.4 过程中的经验复盘与操作建议这个项目做下来有三个经验我想分享给同行供参考。第一测试改造的顺序不能反。一定先把“代码可能在哪些地方骗人”找全再动手写测试。如果一上来就蒙头补测试你大概率只是在原来的测试思路上加数量把覆盖率从 85% 补到 90%但该漏的还是会漏。先做“谎言分析”再做测试设计才是正序。第二不要追求一次性把所有谎言都堵上。有些边界的理想行为是什么产品自己都没想清楚。我在项目里遇到过“weight0 应该怎么处理”这种问题讨论了很久最后决定先降级成默认权重。这个决定不重要重要的是测试把它固化了。边界行为最怕的不是“选择 A 还是 B”而是“这次 A 下次 B”测试能帮你锁定一致性。第三mock 的“坏行为”要写进代码评审规范。我在团队里强调过一个规则mock 外部依赖时至少要包含一个失败路径或异常路径不允许只 mock 成功路径。这个规则执行了半年线上“接口超时导致本地逻辑崩了”这类问题明显减少。很土但很管用。6. “会撒谎”的代码与测试工程师的直觉养成写了这么多年测试我有一个很深的体会测试工程做到后期拼的不是技巧是“怀疑的直觉”。技巧很容易学——边界值分析、状态穷举、变异测试、契约测试每一样都有标准教程。真正拉开差距的是你拿到一段代码时能否本能地问出“这段代码在什么情况下会骗我”。这种直觉很难速成但可以刻意训练。我常用的训练方法是“撒谎演练”拿到一段代码不看实现先根据接口签名和业务描述列出 5 种“代码有可能撒谎的方式”。比如看到public int calculateDiscount(User user, Order order)我会想如果 user 为 null 怎么办如果 order 的金额是负数怎么办如果一个用户同时命中两种折扣规则怎么办如果折扣算出来超过 100% 怎么办列完再去看实现你会发现大部分时候你的“怀疑清单”里至少有两三条是真实存在的坑。另一条经验是测试没跑出 bug不代表代码诚实可能只是你的测试还不够坏。我见过很多人写测试天然希望测试通过于是无意识地选择了能让测试通过的输入。这是人性但测试工程师要反着来——写测试时要刻意选择那些“不太正常”的输入选择那些会让你犹豫“这算不算边界”的输入。好的测试设计者不是要让测试绿而是要让测试尽可能多地逼问代码。最后想说的是测试工程这个领域基础方法其实早就定死了靠的是长期围着真实系统打磨出来的手感和细节。代码会撒谎测试会撒谎覆盖率报表也会撒谎但“怀疑”本身不会。如果你能保持住见谁都不轻信的职业习惯在写每一段代码、每一套测试时多想一层长期积累下来“避坑”这件事就不再靠运气而是靠系统。希望这篇内容能给你一些可以落地的思路。回头有机会我再写一篇关于变异测试在存量系统里怎么低成本落地那个话题也很有意思。