九月初有篇文章把我从"AI 写测试"这个话题的厌倦里拽了出来。发在 dev.to 上,作者 Sergei Parfenov,9 月 11 日发的,16 号改过一版,原文先挂在他自己的站点上。核心论点一句话:AI 生成的测试可能让编码代理表现更差。
前半句我没什么感觉。"AI 生成的测试质量不行"这件事,写过两天代理循环的人都知道,不值得专门写一篇。让我停下来的是后半句的那个方向——更差。不是没用,是负的。
9 月 8 日 Leitian Tao 和同事挂了 ExecCritic 的预印本,里面有一组数:固定同一个 repair 代理(Qwen-3.5-35B-A3B),在 SWE-bench Verified 上跑三种条件。不给生成测试反馈、只给初始修复,解任务率 61.2%;把基础 Qwen Test 代理生成的测试喂回去,57.3%;换成 GPT-5.6-sol 生成的测试,65.3%。三次修复运行取平均,生成的测试是复用的,测试资格失败的时候原始补丁保留在全任务评分里。基线不禁止代理使用仓库自带的测试,问题到底解没解决由另一个独立的官方评估器判。作者自己强调,喂生成测试那一档多花了测试生成和修订的算力,三档的计算预算没有对齐。
我把这三个数抄在便签上:61.2、57.3、65.3。中间那个比第一个低 3.9 个百分点。
这 3.9 个点不该被当成"AI 测试必然更差"的证据,条件没对齐、资格失败样本怎么算,都会晃这个数。但它足够证明一件更要紧的事:反馈是可以带负号的。这句比数字本身重要,因为它直接掀掉了我们写代码时那句口头禅——多写点测试总没坏处。
我不同意那句话。测试不是中性的观察者,它是有方向的力,方向由断言决定。没有测试的时候,代理至少知道自己不知道;有一条骗人的绿检查的时候,代理认为问题解决了,然后停下来。这两种"不知道"的代价根本不在一个量级上。
这一篇不讨论那篇论文对不对,也不复现它的基准——那不是一篇万字文能做完的事,我也不打算假装我做得到。我们造一个夹具:一个本地跑的、只依赖 Python 标准库、不调用任何 LLM、不联网的小项目,它只干一件事——量化你的检查动作值多少钱。你把它敲一遍,就能拿它去照自己项目里的测试套件。
这一篇结束时你手里会有一个两百来行的单文件夹具:同一个函数的三份实现、一组检查、一个能跑出通过/失败矩阵的 runner、一次手挑的变异扫描,还有一张"每条检查唯一拒绝了谁"的账。输出是一张三行三列的表,你一眼能读懂。
被测函数的需求只有三条,三条里塞着 Python 里最经典的一对假值:None 和 []。整个夹具的引爆点就在这儿。
先把被测对象砍到最小
需求先写下来。
省略过滤器、或者显式传 None → 返回全部订单
传空列表 [] → 返回空
传状态列表 ["paid"] → 只返回匹配的订单
三条,看着像废话。但你先记住这三条在"人们会主动测什么"上的分布:第三条是所有人都会想到要测的;第一条是报告里那个 bug 的位置;第二条是没人会主动去测的那条。后面整篇都在围绕这个分布转。
数据结构就一个 dataclass,加一小撮固定订单:
from dataclasses import dataclass
@dataclass
class Order:
id: int
status: str
ORDERS = [Order(1, "paid"), Order(2, "pending"), Order(3, "paid")]
原始实现是报告里那一版——把 None 和空列表一起转成空列表,再拿去过滤:
def filter_orders_original(orders, statuses=None):
statuses = statuses or []
return [o for o in orders if o.status in statuses]
你猜 filter_orders_original(ORDERS) 跑出来是什么?空列表。因为 None or [] 就是 [],in [] 对任何一个订单都是假。这就是被报告的那个缺陷:省略过滤器的时候函数返回空结果,而不是全部订单。
我第一次写这行的时候,是带着"防御性编程"的心态写的。statuses or [] 看着干净、看着安全,顺手把 None 归一化成了一个可迭代对象,后面一行过滤逻辑就不用再判空了。它的问题不在语法,在语义:它把需求上必须区分开的两个输入,合并成了同一种东西。这一步省下来的那点防御成本,后面所有的检查都得绕着这个合并走。
None 和 [] 在 Python 里都是 falsy,这是常识,不用我讲。但"都是 falsy"和"行为应该一样"之间没有推导关系,这一步才是真正容易滑过去的地方。写实现的人滑一次,写断言的人再滑一次,两边滑到同一个坑里,然后互相验证通过。
骨架:三份实现,一个 runner
骨架只干一件事:让"跑检查"这个动作先跑起来。检查先只放一条,就是报告里那个 bug 对应的那条。
三份实现,我按它们在夹具里的角色排好。第一份是原始实现,缺陷方;第二份是那个"看起来合理的补丁";第三份是正确答案。
def filter_orders_patch(orders, statuses=None):
if not statuses: # None 和 [] 一起被当成"没提供过滤器"
return list(orders)
return [o for o in orders if o.status in statuses]
def filter_orders_correct(orders, statuses=None):
if statuses is None: # 只有 None 才是"没提供过滤器"
return list(orders)
return [o for o in orders if o.status in statuses]
补丁这一版值得停一下,因为它是这一篇的第二个主角。它不是随手写出来的错代码——它是当我们盯着报告里那句"省略过滤器时返回空"去修的时候,最容易写出来的东西。缺陷的表象是"None 走进了过滤分支",最直觉的改法就是让 None 别走进过滤分支;而 not statuses 顺手把 [] 也一起拦住了。这就是"看起来合理的修复"的定义:它精确地瞄准了报告的症状,代价是顺手改了另一条需求,而且改得毫无声响。
这里我要先声明一个我自己都犹豫过的设计取舍:我把补丁当成一等的被测对象放进来,而不是当成反面教材摆一下。有三个实现的夹具和两个实现的夹具,复杂度差不多,但能多问出一个问题——"我这组检查能不能把'症状修好了'和'问题修好了'分开"。这个问题比"能不能测出 bug"值钱得多,因为报告里的症状永远只有一个,被顺手改掉的相邻需求永远不止一个。这个取舍我认。
至于 filter_orders_correct(orders, []) 里那个空列表会走到哪里——它不判空,直接进列表推导,in [] 对任何订单都是假,返回空列表。三条需求的顺序是 None 先判、[] 落到过滤逻辑自然出结果,这个顺序就是整个夹具里最容易被写错的一步,也是后面检查要盯住的那一步。
runner 先写最小版:能遍历实现、跑检查、把结果打成矩阵就行。
IMPLEMENTATIONS = {
"original": filter_orders_original,
"patch": filter_orders_patch,
"correct": filter_orders_correct,
}
def run(checks):
width = 14
print(" " * 10 + "".join(f"{name:<{width}}" for name, _ in checks))
for impl_name, fn in IMPLEMENTATIONS.items():
cells = []
for _, check in checks:
try:
cells.append("PASS" if check(fn) else "FAIL")
except Exception as exc:
cells.append(type(exc).__name__)
print(f"{impl_name:<10}" + "".join(f"{c:<{width}}" for c in cells))
那个 try/except 不能省。一个实现抛异常和它断言失败,是两件事,而这两件事在输出里长得一样的时候,你没法判断自己是在读业务结论还是在读一个环境错误——这个坑后面第八节还会正面撞一次。夹具自己先把它兜住:异常就打印异常类名,别让它把整张表打断。
选型上也说明一句:我没用 pytest。pytest 会替我做好 fixture、参数化、报告,但这一篇要造的恰好是"通过/失败矩阵"和"每条检查唯一拒绝了谁"这两个视图,pytest 的标准输出不给你。单文件加标准库还有个好处——任何人拿到它,python fixture.py 就跑,不用装任何东西。代价是没有参数化、没有漂亮的报告,这个代价我认。
检查怎么写:从需求来,不从实现来
骨架里先放的检查只有一条,就是报告对应的那条。现在往里填肉。
CHECKS = [
("default", lambda f: f(ORDERS) == ORDERS and f(ORDERS, None) == ORDERS),
("status", lambda f: f(ORDERS, ["paid"]) == [ORDERS[0], ORDERS[2]]),
("empty", lambda f: f(ORDERS, []) == []),
]
default 那条我把"省略"和"显式传 None"摞进同一个断言里,因为需求第一条本来就把这两件事并列。真实项目里我更倾向拆成两条,失败的时候能一眼看出是哪个入口坏的;这里为了矩阵别太宽,先合并。
status 期望值我写的是 [ORDERS[0], ORDERS[2]],而不是硬编码两个 Order 字面量——保持和输入同一份数据来源,改订单 fixture 的时候不用同步改期望值。这类小事在教学生造的夹具里看着无所谓,在你自己项目的测试里就是"为什么改一条数据挂了八条断言"的来源。
还有个小细节:== 是值比较。假如哪个实现直接把输入列表原样返回而不是拷贝,这条断言照样过。这不是我没想到,是我故意留着——后面变异测试那一节里它会有用。
先跑骨架,只有 default 和 status 两条检查:
default status
original FAIL PASS
patch PASS PASS
correct PASS PASS
骨架立住了。但你看这张表——补丁和正确实现在这两条检查下完全不可区分。整个夹具从这里开始只剩一件事:找出那条能把它们分开的检查。
顺手把"修复前失败、修复后通过"这条经典启发式在这张表上验一遍。它在说什么?它在说:有没有一条检查在补丁前后翻了色。default 在 original 上红、在 patch 上绿,翻得非常漂亮。所以这条启发式会告诉你"这个补丁是个修复"——它说的没错,它只是没告诉你翻色的那条检查有没有覆盖相邻情况。作者在文里说"常见的修复前失败、修复后通过也需要仔细审视",我理解的就是这个意思。
空列表那条检查,和它为什么最难被想到
要把补丁和正确答案分开,只需要把它悄悄改坏的那条需求写成检查:
("empty", lambda f: f(ORDERS, []) == [])
然后矩阵长这样:
default status empty
original FAIL PASS PASS
patch PASS PASS FAIL
correct PASS PASS PASS
这就是这个夹具要交付的东西。一张三行三列的表。
补丁在前两条检查上全绿,只在 empty 上挂。正确实现三条全绿。
补丁和正确答案之间,隔着的那一条检查,形如废话。empty 这条断言难在哪儿?不难在写,一行就完了;难在想到要写它。
你读那三条需求的时候,"省略过滤器返回全部"和"空列表返回空"摆在一起,脑子会自动把它们归成一类——"过滤器的空值处理"。归成一类之后,你就会写一条检查然后收工。只有当这两条需求落到 Python 的假值语义上、变成一对必须区分的状态时,它们才分开。没人会主动去写一条区分两条"都在说空值"的检查,除非他先意识到 None 和 [] 在这个函数里是两个不同的东西。
我在这一步卡的时间比想象中长。不是因为代码难写——两百行夹具,我半天就敲完了。是因为我一开始觉得这三行需求说明不了什么,纯属杀鸡用牛刀;直到矩阵出来我才看明白,这张表真正在量的东西不是"代码对不对",是"你的检查有没有资格判代码对不对"。补丁在前两条检查下是绿的,如果我只跑那两条,我会认为修复完成了。这就是那个负号的微观版本。
变异测试,和它的死角
矩阵只告诉你哪条检查挂了,不告诉你这组检查整体有多硬。想知道后者,通用办法是变异测试。
做法不复杂:对实现做小改动——把 is None 换成 not、把 in 换成 not in、把返回的列表换成空列表——每个改动生成一个变异体;把这组检查套到变异体上跑;检查挂了,这个变异体叫"被杀";全都过,叫"存活"。存活率低,一般被认为测试套件硬。
我手挑了五个变异体打进去,每个都是一份可以直接跑的完整实现:
def m1_drop_none_check(orders, statuses=None): # 砍掉 None 判断
return [o for o in orders if o.status in (statuses or [])]
def m2_invert_membership(orders, statuses=None): # 成员判断取反
if statuses is None:
return list(orders)
return [o for o in orders if o.status not in statuses]
def m3_empty_returns_all(orders, statuses=None): # 空列表也返回全部(就是补丁)
if statuses is None:
return list(orders)
if not statuses:
return list(orders)
return [o for o in orders if o.status in statuses]
def m4_return_original_list(orders, statuses=None): # 返回原列表而不是拷贝
if statuses is None:
return orders
return [o for o in orders if o.status in statuses]
def m5_none_means_empty(orders, statuses=None): # 把"没提供"当成"空过滤器"
if statuses is None:
return []
return [o for o in orders if o.status in statuses]
跑一遍:M1 被 default 杀,M2 被 status 杀,M3 被 empty 杀,M5 被 default 杀。M4 存活。
M4 存活是对的,它是个等价变异体——对支持的输入没有任何可观察的行为差别,只有返回对象的 identity 变了,== 比值的检查抓不到,也不该去抓。这类存活变异体不要去手动"杀",杀它唯一的后果是往生产代码里塞一个多余的拷贝。我第一次用变异测试的时候看到存活就想加断言,后来才明白那叫给检查套件增肥,不叫增硬。
评论区的 Vinh Nguyen 在 CPython 3.14.6 上用 mutmut 3.7.0 跑了这同一个 fixture,得到的是:5 个变异体、5 个被杀死、0 个存活,而正确实现和那个错误补丁拿到的是同一组数字。变异得分 100% 对 100%,这个指标在这里什么都分不开。
我这边多出来的那个等价存活体不改结论,反而把结论讲得更清楚:变异得分是个可以被等价变异体稀释、也可以被变异体选择方式左右的数字。它衡量的是检查对代码改动的敏感度,不是检查对需求理解的敏感度。
变异体是从代码文本机械生成出来的,它跟需求没有关系。补丁的错误在需求层——把 None 和 [] 混了——只要变异体不落在那个混淆点上,两个需求上对立的实现就会长得一样硬。满分对满分,不是变异测试的 bug,是我对它的期待放错了地方。
一条断言值多少钱:唯一拒绝数
矩阵告诉你怎么挂,变异得分告诉你有多硬(而且有可能骗你)。这两个视图我都嫌不够直接。评论区里 howcani 给了这个 fixture 一个我更喜欢的算法:拿一组候选的错误实现去测整个夹具,然后数一件事——一条断言的价值,等于它唯一拒绝的错误实现的数量。
候选错误实现收窄成三种契约误解,作者在文里列的也是这三类:
- E1:完全忽略过滤器,永远返回全部订单。
- E2:把所有缺失的过滤器都当成空过滤器——
None和[]一起返回全部。这一份就是那个补丁。 - E3:把所有空过滤器都当成缺失的过滤器——
None和[]一起返回空。这一份就是原始实现。
然后逐条检查去数它拒绝了谁:
| 检查 | 拒绝的实现 | 唯一拒绝 |
|---|---|---|
| default | E3 | E3 |
| status | E1 | E1 |
| empty | E1、E2 | E2 |
这张账比矩阵有用得多。它把"检查挂了没有"换成了"这条检查在这个夹具里挣到了什么"。empty 之所以是这一篇的主角,不是因为它是第三条写的,是因为它唯一拒绝了 E2——那个通过了前两条检查的补丁。status 的唯一拒绝是 E1,一个连需求第一条都没读懂的实现;default 的唯一拒绝是 E3,报告里那个 bug 本人。三条各挣一份,没有一条是白写的。
同一把尺子也能照出装饰性断言。我加一条:传 ["paid", "paid"] 应该和传 ["paid"] 一样。听起来很合理,防御重复状态的输入。数一下它拒绝了谁——E1 会被 status 挂掉,E2 会被 empty 挂掉,E3 会被 default 挂掉,重复状态这条检查一个唯一拒绝都挣不到。它没拒绝任何订单检查没能拒绝的实现。在这套夹具上,它是装饰。
这不是说生产项目里不该写冗余断言。冗余断言能扛住单条检查被误删,能让失败原因更好定位,能让读的人更快看懂意图。但在评估"我这组检查够不够硬"的时候,冗余和覆盖必须分开算——这两者在通过率里长得一模一样。
另一个更便宜的办法来自评论区的 Kiell Tampubolon:先强制失败。在你手上的实现里故意破坏一行——把返回全部改成返回空、把 is None 改成 not——然后跑你刚加的检查。它必须变红;还原,它必须变绿。如果破坏之后它还绿着,这个检查就是装饰,不需要任何理论分析,一次实验就判了。
这个方法我要专门抬一下。它花的时间比看变异得分少,判得比变异得分准,而且它逼你把"我预期它会红"这句话先说出来。这句话一旦说出口,你就没法再用"反正都绿"糊过去了。
坏测试比没测试更毒:它会反向指路
前面讲的都是漏报——检查太弱,坏实现从缝里钻过去。真正毒的是另一种:断言本身是错的。
我在夹具里加一条反过来的检查:
("wrong", lambda f: f(ORDERS, []) == ORDERS) # 空列表应该返回全部 —— 与需求第二条直接矛盾
加上它之后矩阵变成什么样?patch 是 PASS,correct 是 FAIL。唯一的失败者,是那个写对了的实现。
把这张表交给一个自动修复循环,会发生什么?循环的目标函数是"让检查全绿"。它手上有一个能满足所有检查的补丁,和一个会把检查搞红的正确实现。它没有任何理由去选正确实现——反过来,它现在有了一条明确的理由,去把正确行为改坏。作者说的就是这个:坏测试不只是漏掉缺陷,它会让下一次编辑瞄准错误的目标。
评论区的 Onizuka 讲了一个真实版本。一个分页函数的生成测试把 page=0 和 page=None 当成同一件事,代理照着这个假设发布的修复破坏了第一页的结果,影响了两千多个用户。他说真正让他后怕的不是那个 bug,是代理把绿色检查当成了"问题已解决"的证明,然后停止了检查。
这段话和开头那 3.9 个点摆在一起,就是整个题目的重量。测试是有方向的力,方向由断言决定。方向错了,它会把代理往错误那边推,而且推得比没有测试的时候更有底气——因为这时候代理手里握着的不是"我不知道",而是"我验过了"。
顺带一个同族的现象:断言从实现来,不从需求来。评论区的 Mike Dabydeen 说得比我对——这个 fixture 的缺陷不在代码里,在于那个需求从来没被写下来过。他带学生的时候看到的测试套件的通病是:断言来自刚写完的代码,而不是要求的行为,结果所有断言都通过,但没有任何一条有失败的能力。这就是为什么我坚持先把三条需求抄成代码块再动手:你写在需求下面的断言,和你读完实现之后写的断言,是两种东西。
组装:把方法搬进代理工作流
夹具本身只在本机跑,解决不了任何实际问题。能被搬走的是它的方法。作者给了四件事,我按自己实际会用的顺序重排了一下。
一、让代理动手之前,先把预期行为写下来。 覆盖三类:普通情况、报告里那个问题、以及你最容易混淆的相邻情况。对这个订单函数,相邻情况就是 None 和 []。写的时候不要看实现——这句我说重一点:如果你读完代码才开始写断言,你写出来的一定是代码的行为,不是需求的行为。夹具里那张"唯一拒绝数"的账,能帮你验这件事:一条挣不到唯一拒绝的断言,多半就是从实现抄回来的。
二、像审查生产代码一样审查期望值。 每条断言都要能追溯到需求、兼容性承诺,或者一个独立验证过的例子。追溯不到就删。它对上没挣到覆盖,对下还有可能反向指路——上一节那个 wrong 检查就是这么来的,一行断言,把正确实现变成了唯一的失败者。
三、修复期间,把已经接受的回归检查稳定住。 让一个独立的运行器用这套审查过的检查去评分,测试命令和配置保护起来,别让正在修复的那个上下文去改。ExecCritic 走的就是这个路子:把测试创建和修复拆成两件事,测试在原始仓库上先验证过,修订期间保持不变——目的就是把"改目标"这个能力从修复器手里拿掉。但得说清楚,分离上下文和权限,不等于两个代理都正确理解了问题。评论区的 Shruti Saraswat 提的是更根本的一层:把测试编写者的上下文和补丁编写者的上下文分开,是因为如果两边共享同一个错误假设,这个假设会同时出现在实现和测试里,然后互相验证通过,两边都错得理直气壮。分工能减少串味,不能凭空长出一个正确的理解。
四、看失败原因,别只看失败。 让代理照着失败去修之前,先确认这次失败是"返回的订单不对",还是"依赖没装上""导入失败""一条检查都没被选中"。这四种失败在流水线输出里可能都只是一行红字,但对应的动作完全不同——后三种你放代理进去,它会去修一个不存在的问题。这也是我在夹具 runner 里那个 try/except 的用处:让"实现抛异常"和"断言失败"在输出里长得不一样,从最小的地方开始。
边界
这一段我讲得比一个夹具该讲的长了,因为它才是这一篇真正想交付的东西。夹具只是把它变得能跑。
这个玩具没做什么,我交代清楚:它不调用任何 LLM,不联网,只依赖 Python 标准库;它包含三份实现、检查代码和验证输出,检查是手写的,变异体也是手挑的,没有做基于 AST 的自动变异。ExecCritic 的架构我只讲了它对测试生命周期的处理方式,没有复现它的实现。开头那组 61.2 / 57.3 / 65.3 是论文作者的结果,不是我做的基准复现——你想拿它当证据,得自己去看论文的条件和预算,尤其是计算预算没有对齐这一条,我是认真在提醒,不是走流程。
还有一点我自己没想清楚:怎么在真实项目里给"相邻情况"划边界。None 和 [] 这对是明显的,因为需求里写了两条;但一个函数如果有十来个状态维度,哪些相邻组合值得写断言、哪些写下去就是装饰,我没有一个能随手套的判据。作者给的那个提问是个好起点——"这个测试会拒绝哪种看似合理的错误实现?"——回答得出来就写,回答不出来就先别写。这个判据粗,但它至少逼你回答一次。
到这里,我们造出来了什么
一个两百来行的 Python 单文件:三份实现、三条检查、一个打矩阵的 runner、五个手挑的变异体,外加一张每条检查唯一拒绝了谁的账。从空目录到跑出那张三行三列的表。跑一下看看,然后你会做一件以前不会做的事——盯着你那片绿,问一句它到底拒绝了谁。
下一步往哪走,两个方向值得。一是把它接进一个真实的代理循环跑一次闭环:让代理生成检查、我审一道、再让它照着失败去修,看它是不是真的会把正确实现改坏——这一步花的是时间,不是算力。二是给这个夹具换一个更脏的函数:带分页、带排序、其中一个字段在不同状态下含义不同,看 None 和 [] 那对假值会以什么新形式再出现一次。
这两件事都比再写一个玩具夹具价值大。真要说这一篇留下了什么,就是那张表,和你下次看见一片绿时第一反应的变化。
