自动化真正拦下来的 bug,长什么样
接口自动化系列第三篇。前两篇讲用例设计的四条原则和透明加密 Session,这篇讲这套东西到底拦下了什么。
案例都做了脱敏:产品、接口路径、字段名全部改写,只保留缺陷模式本身。
做完自动化经常会被问一个问题:“所以它到底抓到 bug 了吗?”
这个问题很实在,也很难糊弄——因为“覆盖了 14 个模块”“567 条用例”这类数字,只说明你干了多少活,不说明这些活有没有用。
所以这篇写四个具体的案例。挑它们不是因为它们最严重,而是因为它们分别代表了四类手工回归很难发现的问题。
一、不传任何过滤条件,删掉整个团队的数据
这是最惊险的一个。
有个批量删除接口,正常用法是传一组过滤条件——选中的 ID 列表、排除的 ID 列表、时间范围、搜索关键词——服务端按条件命中,然后删除。
我在写反向用例时想了个很自然的场景:如果一个过滤条件都不传呢? 按常理应该报错,提示“未选择任何内容”。
实际结果是:status=0,deletedCount=9——整个团队的数据被清空了。
读服务端代码找到了原因:查询函数在发现所有过滤字段都为空时,会自动补一个 [0, 现在] 的时间窗兜底。这个默认值本意大概是“没指定范围就查全部”,在查询场景下是合理的;但删除接口复用了同一个查询函数,于是“没指定范围”就变成了“删全部”。
POST /team/photo/delete
{"userID": "<u>", "groupID": "<g>"} ← 没有任何过滤条件
→ status=0, deletedCount=9 ← 全没了
危险在于触发它一点都不难:前端某次改版把筛选条件清空后没做拦截、某个重试逻辑漏传了参数——用户点一下“删除”,整个团队的数据就没了。而且这类操作通常不可撤销。
这个案例有两点值得记下来。
第一,“缺省即全集”在查询里是便利,在删除里是灾难。 查询函数被删除接口复用,是很常见的代码组织方式,也正是这类问题的温床。以后看到删除接口,我都会专门问一句:它的命中范围是谁算的?那个函数是不是查询也在用?
第二个是关于测试自己的。 发现这个问题之后,我的反向用例反而不能这么写了——每跑一次就把测试数据删光一次,后面的用例全得挂。最后改成传一个不存在的 ID,走“按 ID 精确删”的路径,让它命中空集合返回拒绝码,既覆盖了异常分支,又不会误伤数据。
写破坏性接口的反向用例时,先想清楚“如果服务端的行为跟我预期的不一样,会发生什么”。我这次是运气好,炸的是测试环境。
二、过滤参数形同虚设
一个搜索接口支持按类型过滤,参数是个数组。我写参数化用例铺了三组值:[1]、[2]、[99](一个非法值)。
预期是:前两组各自返回对应类型的子集,第三组返回空或报错。
实际结果是三组全都返回 totalCount=91——和完全不传这个参数时一模一样。
三种不同的输入,三种相同的输出,其中还包括一个非法值。结论很清楚:这个参数根本没被使用。 请求参数一路透传到了下游,但下游压根没实现这个维度的过滤。
这个 bug 的性质值得琢磨:它不会让任何接口报错,监控看不到,日志里也干干净净。前端把“只看图片/只看视频”的开关做出来了,用户点了,结果没变化——用户大概率以为是自己没点对。
而它之所以能被自动化抓到,恰恰是因为参数化的写法天然构成了对照实验:同一个接口、同一套断言,只有那一个参数不同。三组结果一比,异常立刻显形。
如果是手工测试,多半会挑一个值点一下,看到“返回了数据、没报错”,就过了。
参数化不只是省代码,它还是一种对照实验。 一个参数取多个值跑出完全一致的结果,这件事本身就是信号。
三、删除成功了,但列表里还在
一个移除成员的接口,返回 status=0,明确告诉你成功了。紧接着查成员列表,被移除的人还在里面。等 5 秒再查,还在。
读代码看到,移除操作里有一个异步的缓存清理(go clearCache(...)),而查询列表走的可能是另一份没被刷新的缓存。两边不同步。
这个案例的价值不在 bug 本身,而在于它逼我想清楚一个问题:这条用例应该断言什么?
最初我想断言“移除后列表里没有他”——这是第一篇里说的“验业务真的发生了”,本来是对的。但如果这个延迟是产品有意设计的最终一致性呢?那我的断言就是错的,用例会一直红,最后被人加个 skip 了事。
我最后的处理是分成两件事:
- 用例侧:只断言接口返回
status=0,并在 docstring 里写清楚“此处不验证列表一致性,原因是存在缓存延迟,见问题记录 #3”。 - 问题侧:把它记进问题记录,附上复现路径,请后端确认——是漏清了缓存键,还是有意的最终一致性窗口? 如果是后者,请在接口文档里写明这个窗口有多长。
这个区分我觉得挺重要:测试的产出不只是“用例红了”,还包括“这里的行为没有定义”。 后者往往比前者更有价值——一个没被定义的行为,今天表现成延迟 5 秒,明天可能表现成永远不同步,而没有任何人会觉得自己写错了。
顺带说,同一份问题记录里还躺着好几条类似的“契约缺口”:一个字段没有做枚举校验、一个本该生效的上限截断是死代码、接口清单里有五个接口早就下线但表格没更新、一个接口在文档里说支持表单提交但实现只认 JSON。这些都不是“功能不对”,而是说的和做的不一致——自动化在逐个接口对照实现写断言的过程中,会非常自然地把它们撞出来。
四、一条自己骗自己的用例
最后这个不是服务端的 bug,是我的用例的 bug。我把它放进来,因为它比前面三个都更值得警惕。
第一篇里提过,这里说完整。
业务规则是“管理员不能被移除”。我写的用例是:让普通成员去调删除接口删管理员,断言返回拒绝码 -140。
跑通了,绿的,我以为覆盖了这条规则。
后来读鉴权链的代码才发现:普通成员根本没有“删人”这个权限,请求在前置鉴权阶段就被拦掉了,压根没走到 handler 里那句“目标是管理员则拒绝”。而两条路径返回的恰好是同一个错误码。
也就是说——那条业务规则,从头到尾没有被测过一次。用例是绿的,覆盖率报告是好看的,实际保护是零。
修法是先给这个成员赋予“删人”权限,让请求穿过前置鉴权、真正抵达业务规则,这时候再断言拒绝。并且补一条 msg 断言,确认拦截来自哪条路径:
assert data["status"] == -140
assert "owner" in (data.get("msg") or "").lower() # 确认是业务规则拦的
这件事之后我加了一条自查:凡是反向用例,都要能回答“我怎么确认它走到了我要测的那行代码”。
错误码相同不代表逻辑相同。一个系统里往往有好几条路径通向同一个拒绝码——参数校验、鉴权、业务规则——而反向用例最容易在这里自欺欺人:你期望它红的地方它确实红了,但红的原因不是你以为的那个。
这类假阳性比漏写用例更危险。漏写你至少知道自己没覆盖;假阳性会让你以为自己覆盖了。
一点总结
回看这四个案例,有个共同点:没有一个是正向用例能发现的。
- 空条件删全库——要主动去构造“什么都不传”这个反常输入
- 过滤参数没实装——要多个取值做对照,单点验证看不出来
- 缓存不同步——要在写操作之后再查一次,只看写接口的返回值永远发现不了
- 假阳性用例——要读实现代码才能发现,跑多少次都是绿的
这也解释了为什么最后那个项目里反向用例占到了一半以上。这个比例不是为了好看凑出来的,是往“能拦 bug”的方向写,自然就到了这个位置。
还有一条贯穿始终的:四个案例里有三个的根因,是读服务端代码发现的,不是跑出来的。自动化提供的是“能反复、精确、低成本地验证一个猜想”的能力,而猜想本身来自对系统的理解。
工具负责验证,人负责怀疑。这两件事目前还换不了位置。