免费 AI 模型改变了测试经济学:生成补丁成本趋零,瓶颈转移到测试验证环节;变异测试是系统性发现测试盲点的最廉价方式。
我们争论 AI 徽章是否能量化什么,却很少质疑我们的测试套件是否真的能检测到一个缺陷。免费模型访问改变的是验证的经济学,而非代码生成的经济学。生成一个补丁的边际成本接近于零,所以瓶颈下游转移到必须拒绝坏补丁的测试上。突变测试是最低成本的方式来发现这些测试到底能不能失败,而免费服务器正是做这笔计算的正确地方。
大多数针对 AI 生成代码的审查关卡都假设测试套件是可信的,而这个假设几乎从未被测量过。每注入一个 fault 就运行一次测试套件,会将 CI 时间乘以突变体数量,这就是为什么突变测试在付费 runner 上通常被跳过。免费服务器把决策从"跳过它"变成"每晚运行一次",输出的是一个具体的测试缺口列表,而不是又一份要读的 diff。
向审查关卡喂已知坏补丁,一次测试一个决策。突变测试是这个想法的系统化版本,因为它枚举了你的套件能够看到的故障类别,而不依赖审查者的想象。生成的测试不是替代品,因为它们倾向于镜像它们所测试代码中的假设,而突变体存在正是为了打破那些假设。
这不是关于每个已验证变更的成本问题;而是关于验证步骤能否检测到变更的问题。如果你的套件在翻转一个操作符时都无法失败,那它也无法被信任来评判 AI 补丁。
下面是一个最小的、可运行的突变测试器,它对每个突变体翻转一个比较操作符,并针对每个突变体运行 pytest。它需要 Python 3.9 或更高版本,因为它使用了 ast.unparse,并且有意只突变每个比较的第一个操作符以保持可读性。脚本在每次运行后恢复原始文件,所以一个失败的突变体永远不会泄漏到你的工作树中。
#!/usr/bin/env python3
"""mutate.py — flip one comparison operator per mutant and run pytest.
Usage:
python mutate.py target.py test_target.py
"""
import ast
import copy
import subprocess
import sys
from pathlib import Path
FLIPS = {
ast.Lt: ast.Gt,
ast.Gt: ast.Lt,
ast.LtE: ast.GtE,
ast.GtE: ast.LtE,
ast.Eq: ast.NotEq,
ast.NotEq: ast.Eq,
}
def mutant_sites(tree):
for node in ast.walk(tree):
if isinstance(node, ast.Compare) and node.ops and type(node.ops[0]) in FLIPS:
yield node
def main() -> int:
target_path, test_path = Path(sys.argv[1]), Path(sys.argv[2])
original = target_path.read_text()
base = ast.parse(original)
total = killed = 0
for site in mutant_sites(base):
old = type(site.ops[0])
tree = copy.deepcopy(base)
for candidate in ast.walk(tree):
if (isinstance(candidate, ast.Compare)
and candidate.lineno == site.lineno
and candidate.col_offset == site.col_offset):
candidate.ops[0] = FLIPS[old]()
break
target_path.write_text(ast.unparse(tree))
try:
result = subprocess.run(
[sys.executable, "-m", "pytest", str(test_path), "-q"],
capture_output=True,
text=True,
)
total += 1
if result.returncode != 0:
killed += 1
print(f"KILLED line {site.lineno}: {old.__name__} flipped")
else:
print(f"SURVIVED line {site.lineno}: {old.__name__} flipped")
finally:
target_path.write_text(original)
if total == 0:
print("No comparable operators found; nothing to mutate.")
return 1
print(f"\n{total} mutants, {killed} killed, score {killed / total:.0%}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
将脚本保存为 mutate.py,放在目标模块及其测试的同一目录下。
在本地运行一次以确认输出格式:python mutate.py pricing.py test_pricing.py
阅读存活者列表;每个存活者要么是一个等价突变体,要么是一个真正的测试缺口。
将同样的任务安排在 MonkeyCode 的免费服务器选项上,这样它每晚运行而不会消耗付费 CI 分钟。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。免费模型访问对于下面的分类步骤很有用,因为对存活者进行分类是一个小的、可验证的任务。
对于每个存活者,用简短的提示让免费模型对其进行分类,然后通过自己阅读代码来确认推理。
考虑一个有两个边界比较的 shipping-cost 函数。
def shipping_cost(weight_kg: float, express: bool) -> float:
if weight_kg <= 1.0:
base = 4.0
elif weight_kg <= 5.0:
base = 7.0
else:
base = 10.0
return base * 1.5 if express else base
以及一个只覆盖明显情况的测试文件。
from pricing import shipping_cost
def test_light_standard():
assert shipping_cost(0.5, False) == 4.0
def test_heavy_express():
assert shipping_cost(6.0, True) == 15.0
运行突变器会得到这份报告。
KILLED line 2: LtE flipped
SURVIVED line 3: LtE flipped
2 mutants, 1 killed, score 50%
第一个突变体死了,因为 test_light_standard 钉住了 1.0 kg 边界。第二个突变体存活了,因为两个测试都使用在 5.0 kg 边界同一侧的权重,所以翻转后的条件对套件中的每个输入都返回相同的结果。那个存活者是一个缺失的测试用例,不是一个等价突变体,当你用这个提示喂养它时,免费模型应该这么说。
Original code:
elif weight_kg <= 5.0:
base = 7.0
Mutated code:
elif weight_kg > 5.0:
base = 7.0
Result: pytest passed, so the mutant survived.
Question: is this an equivalent mutant or a missing test case?
Answer in one sentence, and propose a test if the mutant is meaningful.
验证很简单:shipping_cost(5.0, False) 在原始代码中返回 7.0,在突变体中返回 10.0,所以边界是真实存在的。添加缺失的测试,重新运行突变器,分数移动到 100%。
def test_boundary_five_kg():
assert shipping_cost(5.0, False) == 7.0
阅读存活者表格
用表格作为分诊指南而不是判决。每个在 <= 或 >= 下的存活者几乎总是意味着确切的边界值缺失于你的输入中,而每个在 == 或 != 下的存活者通常意味着等式路径的一侧从未被到达过。跨多个存活者的模式比任何单行都更重要。
突变测试有三个诚实的局限性。等价突变体产生误报,仍然需要人工分类,因为翻转操作符可能使可观察行为保持不变。操作符翻转突变无法找到缺失的逻辑,因为你无法突变首先就不存在的代码。而高杀灭率测量的是敏感性而非正确性,所以一个套件可以杀死每个突变体但仍然遗漏集成故障。
flaky 测试使结果变得嘈杂,所以如果你的套件不稳定,每个突变体运行多次。生产级工具如 Python 的 mutmut、JavaScript 的 Stryker 和 Java 的 PIT 会枚举完整的突变矩阵,而本脚本在每个比较的第一个操作符处停止。把分数视为跨提交的趋势线,而不是通过/失败的关卡,因为在一个小模块上单次夜间运行是一个样本,不是一次普查。谁不应该使用这种方法:根本没有测试套件的团队,以及测试主要是端到端 UI 流的团队,因为单元级突变体会因为与被测代码无关的原因而死亡。
知道你的审查关卡能否失败的最便宜的方法是故意让它失败。运行一次突变器,分类存活者,你就能确切地知道下次 AI 补丁到来之前应该写哪些测试。这比另一个生成的函数是免费模型访问的更好用途,而免费服务器是每晚做这笔计算的正确地方。