拆穿“会撒谎的代码”:测试设计的三大谎言与对抗策略
2026/9/24 22:18:49 网站建设 项目流程

1. 一次“全绿”却翻车的事故:代码是怎么骗过我的

先说一件我踩过的真事。几年前我负责一个订单状态流转模块,改动了一个内部方法,把原来同步的状态更新改成了带缓冲的异步写入。单测跑了一遍,全绿,覆盖率 87%,CI 直接放行。上线当天就出了事故——用户看到订单状态没有变化,而后台数据库里字段其实已经改了。后来定位发现,问题就出在“测试通过了”这件事本身:我的单测里根本没有等待异步写入完成就立刻断言,断言读到的永远是旧值。测试报告告诉我“没问题”,但代码在真实环境里撒了个大谎。

这件事给了一个很深刻的教训:测试工程的本质,不是证明代码“在工作”,而是识别代码“在什么时候会骗你”。大多数测试写不好,不是因为不会写,而是因为默认代码是诚实的——默认它按注释说的做、按你记忆中的逻辑做、按正常路径做。但代码恰恰是最会撒谎的东西:边界条件会撒谎、并发逻辑会撒谎、被 mock 掉的外部依赖会撒谎,甚至测试代码自己也会撒谎。

这篇内容,我围绕“会撒谎的代码”展开,聊聊为这类代码写测试时真正需要面对的问题:假象从哪来、测试怎么设计才不会轻易被骗、以及如何识别测试体系里那些“自欺欺人”的环节。适合刚把测试当成正式工程实践的开发者,也适合正在为存量系统补测试的质量或后端工程师参考。

2. 三类最常见的“谎言”:边界、状态与假依赖

想拆穿代码的谎言,得先搞明白代码最喜欢在哪些地方说谎。根据我这些年跟测试死磕的经验,绝大多数“代码骗过了测试”的情况,都能归到下面三类里。

2.1 边界条件说谎:条件判断比你以为的更早或更晚

代码最经典的谎言,就是“差一点”。举个例子,你写了一个分页函数:

public List<Item> getPage(List<Item> all, int page, int size) { int fromIndex = (page - 1) * size; int toIndex = Math.min(fromIndex + size, all.size()); return all.subList(fromIndex, toIndex); }

如果你只测了 page=1、page=2、page=3 这种正常值,测试大概率是全绿的。真正骗你的是 page=0、page=-1,或者 size=0。page=0 时 fromIndex 是 -1,subList 直接抛 IndexOutOfBoundsException;size=0 时 fromIndex 和 toIndex 相等,返回空列表——这也许是对的,也许不是,取决于产品定义。但你的测试没测这些,所以代码可以在“你没看过的地方”自由撒谎。

边界条件是说谎重灾区,因为它们只出现在特定输入组合下。你写测试时脑子里默认“参数都是合理的”,但代码从不管你是不是合理,它只会机械执行。

2.2 状态说谎:同一个方法,不同状态下行为完全不同

更隐蔽的谎言是“状态相关”。同一个函数,在用户已登录和未登录时行为不同,在缓存命中与未命中时行为不同,在第一次调用和第二次调用时行为不同。如果你测试时没有显式构造状态,代码会用“它自己默认的状态”骗你。

我见过一个很典型的例子。一个支付模块的isAllowedToRefund()方法,内部依赖一个内存缓存判断用户是否在黑名单里。测试里这个方法返回 true,因为缓存是空的;但生产环境黑名单缓存里恰好有数据,同一段代码在线上行为完全反转。代码没有变,变的是状态,但测试报告照样显示“通过”。

测试工程师要永远记住一件事:代码的诚实性依赖于前置状态,而不是代码本身。你测试的不是一段孤立逻辑,而是一个“在特定状态下的逻辑”,状态的假设没写在测试里,就意味着假象。

2.3 假依赖说谎:mock 出来的世界,从来不会像真实世界那样捣乱

第三种谎言来自依赖。测试里我们经常 mock 掉外部服务、数据库、消息队列,这是好事,能隔离不确定性。但 mock 也会撒谎——它太听话了。

真实的外部依赖会超时、会返回乱序数据、会在重试时恰好重复提交、会在网络抖动时部分失败。而你 mock 的依赖永远是“正常返回一个你写死的值”。所以你会看到这样的现象:单测全绿,一接真实环境立刻挂,因为真实依赖参与了测试才暴露问题。

我给个具体例子。你 mock 了一个支付网关的响应,固定返回 SUCCESS,然后测自己的回调逻辑,测试通过。但真实网关可能在 5 秒后回调、可能回调两次、可能在返回 SUCCESS 之后又发一个 REVERSED。这些行为你的 mock 全部假装没发生——这就是假依赖在替你撒谎。

3. 拆穿谎言的三板斧:边界钻孔、状态穷举和契约立据

知道了代码在哪几个方向撒谎,接下来就要用工程手段拆穿它。我的经验里,有三套方法对“拆谎”特别有效,能覆盖掉前面三类问题的大头,但每套方法都有前提和适用边界,下面详细展开。

3.1 边界钻孔:把“差一点”的地方全部炸开

第一板斧是专门针对边界条件的。思路很简单:不要等 bug 出现在边界上,而是主动把边界周围的所有值都钻一遍。

以刚才的分页函数为例,写测试的时候不要只写 page=1、2、3。你要钻井盖一样,把page=-1page=0page=1page=最大值size=0size=1size=负数fromIndex + size 恰好等于 all.size()fromIndex + size 恰好溢出 int这些情况全测一遍。

具体做法是:把这个方法当成一个“边界函数”来分析。凡是存在-101最大值最小值size-1size+1这类关键值的地方,全部作为独立测试用例写进去。不要只写正常路径,然后把边界值顺手带上;要让每个边界值都成为一个有名字、有断言的测试用例,比如:

@Test void pageZero_shouldReturnFirstPage() { // page=0 时,页面语义通常期望返回第一页 List<Item> result = getPage(items, 0, 10); assertEquals(expected, result); } @Test void negativePage_shouldThrowOrReturnEmpty() { // 负页码:明确选择一种行为并固化下来 assertThrows(IllegalArgumentException.class, () -> getPage(items, -1, 10)); }

这样做的价值有二:一是边界异常发生时测试名字直接告诉你“哪种边界出了事”;二是把行为决策固化下来,后面人改代码时跑一遍测试就能看出原来的意图。

这里我要强调一个关键点:边界钻孔不是把输入值“凑一遍”,而是把行为分支“逼出来”。一个条件判断if (a > 10),真正要测的是 a=10 和 a=11 之间行为是否如预期切换,而 10 和 11 就是边界。做一轮“分支覆盖分析”,把代码里所有<><=>=equalsnull判断都找出来,然后每个判断的临界点写透,这一步做完,大多数边界谎言基本无处遁形。

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 测试写满了断言,但断言什么都没验证

这是我见过最普遍的“测试谎言”:测试方法里代码一大坨,断言写了一堆,但仔细看,每个断言都在重复被测代码的内部实现,而不是验证真实行为。

典型的例子是测一个排序功能,测试这么写:

List<Integer> 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));

这个测试没问题。有问题的版本是这样的:

List<Integer> input = new ArrayList<>(Arrays.asList(3, 1, 2)); List<Integer> 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 的场景,我专门写了一个测试用例,用@Test+assertThrows把这个行为固定下来——产品确认 weight=0 时应该降级为默认权重,而不是抛异常,所以修完代码之后,这个测试就成了保护伞,再有人改成直接除,测试立刻红。

第二维是状态对抗。我写了一个“缓存未初始化”的测试:

@Test void whenLoadCacheEmpty_shouldFallbackToDatabaseWeights() { when(loadCache.fetchAll()).thenReturn(emptyMap()); List<Assignment> 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 的场景,在配置加载时加校验,默认 weight=1;缓存未初始化时增加降级逻辑,直接回源数据库读取权重再进缓存;用户等级为 null 时,默认按普通会员处理。

这个过程中,我反复体会到一个现象:好的测试能逼着代码变诚实。不需要测试工程师苦口婆心地劝开发“你这里加个判空吧”,测试红在那里,代码不修就发布不了,所有人都主动把边角补上了。这和“测试只是验证一下有没有 bug”是完全不同的思路——测试不是在检查代码,而是在定义代码必须遵守的契约。

改造完成后,同一套模块的覆盖率还是 85% 左右,但线上投诉基本清零了。区别不在于覆盖率的数字,而在于覆盖的“行为空间”彻底变了:那些曾经能自由撒谎的边界、状态、依赖缝隙,全部被测试钉死了。

5.4 过程中的经验复盘与操作建议

这个项目做下来,有三个经验我想分享给同行,供参考。

第一,测试改造的顺序不能反。一定先把“代码可能在哪些地方骗人”找全,再动手写测试。如果一上来就蒙头补测试,你大概率只是在原来的测试思路上加数量,把覆盖率从 85% 补到 90%,但该漏的还是会漏。先做“谎言分析”,再做测试设计,才是正序。

第二,不要追求一次性把所有谎言都堵上。有些边界的理想行为是什么,产品自己都没想清楚。我在项目里遇到过“weight=0 应该怎么处理”这种问题,讨论了很久,最后决定先降级成默认权重。这个决定不重要,重要的是测试把它固化了。边界行为最怕的不是“选择 A 还是 B”,而是“这次 A 下次 B”,测试能帮你锁定一致性。

第三,mock 的“坏行为”要写进代码评审规范。我在团队里强调过一个规则:mock 外部依赖时,至少要包含一个失败路径或异常路径,不允许只 mock 成功路径。这个规则执行了半年,线上“接口超时导致本地逻辑崩了”这类问题明显减少。很土,但很管用。

6. “会撒谎”的代码与测试工程师的直觉养成

写了这么多年测试,我有一个很深的体会:测试工程做到后期,拼的不是技巧,是“怀疑的直觉”。技巧很容易学——边界值分析、状态穷举、变异测试、契约测试,每一样都有标准教程。真正拉开差距的,是你拿到一段代码时,能否本能地问出“这段代码在什么情况下会骗我”。

这种直觉很难速成,但可以刻意训练。我常用的训练方法是“撒谎演练”:拿到一段代码,不看实现,先根据接口签名和业务描述,列出 5 种“代码有可能撒谎的方式”。比如看到public int calculateDiscount(User user, Order order),我会想,如果 user 为 null 怎么办?如果 order 的金额是负数怎么办?如果一个用户同时命中两种折扣规则怎么办?如果折扣算出来超过 100% 怎么办?列完再去看实现,你会发现大部分时候你的“怀疑清单”里,至少有两三条是真实存在的坑。

另一条经验是:测试没跑出 bug,不代表代码诚实,可能只是你的测试还不够坏。我见过很多人写测试,天然希望测试通过,于是无意识地选择了能让测试通过的输入。这是人性,但测试工程师要反着来——写测试时要刻意选择那些“不太正常”的输入,选择那些会让你犹豫“这算不算边界”的输入。好的测试设计者不是要让测试绿,而是要让测试尽可能多地逼问代码。

最后想说的是,测试工程这个领域,基础方法其实早就定死了,靠的是长期围着真实系统打磨出来的手感和细节。代码会撒谎,测试会撒谎,覆盖率报表也会撒谎,但“怀疑”本身不会。如果你能保持住见谁都不轻信的职业习惯,在写每一段代码、每一套测试时多想一层,长期积累下来,“避坑”这件事就不再靠运气,而是靠系统。

希望这篇内容能给你一些可以落地的思路。回头有机会我再写一篇关于变异测试在存量系统里怎么低成本落地,那个话题也很有意思。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询