作者详细分享了用 AI 编程工具为遗留代码库批量补充测试的完整流程,指出真正有意义的指标是突变测试覆盖率而非行覆盖率,且人工审查时间才是真实瓶颈。
我接手了一个 5 年历史的 TypeScript 服务,测试覆盖率仅有 6%,花了三周时间让 Claude Code 向其中补写 1,200 个测试。覆盖率从 6% 升至 71%,但真正有意义的数字是变异分数(34% → 58%),而真正的瓶颈原来是我自己的评审时间,而非 agent 的吞吐量。
这个服务是一个 Node.js 22.x / TypeScript 5.x 的账单相关 API。5 年历史,4 任前负责人,约 62,000 行代码。它在运行,在赚钱,没人愿意碰它。
测试套件有 340 个测试,几乎全部来自 2022 年某个人做过 TDD 后又放弃的工具模块。行覆盖率:6%。其他所有部分——定价规则、重试逻辑、Webhook 广播——都是零测试,某个地方还有一条注释说 // don't change this, it breaks the invoice job。
实际后果:每次改动都要花 3 天。写代码 1 小时,手工 QA 和盯着 staging 日志看 2 天,因为没有什么能告诉你是否破坏了 invoice job。
我每天都在用 Claude Code 做功能开发,它在"为你刚写的函数写个测试"这件事上表现不错。这是简单的场景——agent 有上下文中的意图,所以测试编码了意图。
补写测试是困难场景,而且难在一个特定原因:
当你为已经在线上运行的代码写测试时,你不知道代码应该做什么。你只知道它做了什么。这是两件不同的事,其中一个有时候是一个悄悄上线了 4 年的 bug。
让 agent"给这个文件添加测试",它会读取源代码,从函数名推断意图,然后写测试来断言代码应该做什么。然后一半的测试失败,agent——出于好意——调整断言直到它们通过。现在你有了 40 个绿色测试,它们什么都不断言,只断言"代码做了代码做的事",写法对下一个人来说极其费解。
我想要相反的东西:明确锁定当前行为的测试,大声标记看起来有问题的部分,并给我一个真正可以用于重构的安全网。
三件东西:按风险挑选目标的工作列表、强制执行表征而非猜想的每文件契约、以及不是行覆盖率的质量门。
flowchart TD
A[Worklist: churn x coverage gap] --> B[Pick highest-risk untested file]
B --> C[Capture runtime behavior]
C --> D[Agent writes characterization tests]
D --> E{Suite green?}
E -- no --> F[Agent files a SUSPECT note, never edits src]
E -- yes --> G[Mutation run on that file]
G --> H{Mutation score >= 50%?}
H -- no --> D
H -- yes --> I[Human review queue, batched by risk]
F --> I
我的第一次尝试按目录顺序遍历 src/,为 2023 年以来没人改过的配置加载器生成了 200 个优秀的测试。毫无用处。需要安全网的文件是那些一直在被修改的文件。
所以我从 git 历史和覆盖率报告交叉生成了工作列表:
#!/usr/bin/env python3
"""Rank untested files by risk = commit churn x uncovered lines."""
import json, subprocess
from collections import Counter
SINCE = "18.months.ago"
log = subprocess.run(
["git", "log", f"--since={SINCE}", "--name-only", "--pretty=format:"],
capture_output=True, text=True, check=True,
).stdout
churn = Counter(
line for line in log.splitlines()
if line.endswith(".ts") and not line.endswith(".test.ts")
)
# istanbul/v8 json summary from the existing (tiny) suite
cov = json.load(open("coverage/coverage-summary.json"))
ranked = []
for path, hits in churn.items():
entry = cov.get(path)
if not entry: # never imported by any test = fully dark
uncovered = 1.0
else:
pct = entry["lines"]["pct"]
if pct >= 80: # already protected, skip
continue
uncovered = (100 - pct) / 100
ranked.append((hits * uncovered, hits, round(uncovered, 2), path))
for score, hits, unc, path in sorted(ranked, reverse=True)[:60]:
print(f"{score:7.1f} churn={hits:<4} uncovered={unc:<5} {path}")
这个列表的顶部是 6 个文件。这 6 个文件出现在我们那年写的每份 postmortem 中。这种相关性不是巧合,这是整篇文章中单杠杆效应最高的一件事:churn × darkness 就是你的 incident 地图。
这是让"描述代码的测试"和"描述行为的测试"产生区别的部分。
在 agent 写任何东西之前,我用真实形状的输入捕获了函数的实际行为。一个粗糙的追踪包装器,转储到 JSON:
// tools/trace.ts — wrap exports, record (args, result) pairs, dump to disk.
import { appendFileSync } from "node:fs";
type AnyFn = (...args: unknown[]) => unknown;
export function trace<T extends Record<string, AnyFn>>(mod: T, name: string): T {
const out = {} as T;
for (const [key, fn] of Object.entries(mod) as [keyof T, AnyFn][]) {
out[key] = ((...args: unknown[]) => {
let result: unknown, threw: unknown;
try {
result = fn(...args);
} catch (e) {
threw = e instanceof Error ? { name: e.name, message: e.message } : e;
throw e;
} finally {
appendFileSync(
`traces/${name}.jsonl`,
JSON.stringify({ fn: key, args, result, threw }) + "\n",
);
}
return result;
}) as T[keyof T];
}
return out;
}
我运行了现有的集成冒烟测试套件和一天去敏后的 staging 流量回放。结果:traces/ 中有几千万真实 (input, output) 对。
然后 prompt 就可以说 here is what this function actually returns,这把"推断意图"变成了"编码观察"。输出质量天差地别。
每个文件一个 session,契约保存在一个临时文件中并粘贴进去。承载所有重量的四条规则:
You are writing CHARACTERIZATION tests for a legacy file. Rules:
1. NEVER edit anything under src/. You may only create or edit the *.test.ts
file for the target. If a test cannot pass without a src change, that is a
finding, not a blocker.
2. Assert what traces/<module>.jsonl shows the code ACTUALLY does — including
behavior that looks wrong. Do not "fix" assertions to be sensible.
3. If observed behavior looks like a bug, still pin it. Do not skip the test —
write it as a passing test with a flagged name:
it("SUSPECT: returns 0 instead of throwing on negative qty", ...)
and add one line to FINDINGS.md explaining why it looks wrong.
4. No mocking of the module under test. Mock only I/O boundaries (network, fs,
clock). If a function is untestable without mocking itself, say so and stop.
规则 1 是我会据理力争的那条。一旦 agent 可以编辑 src/ 来让测试通过,你就不再有安全网——你有一个 agent 在凌晨 2 点悄悄重写生产行为来满足它自己的断言。源代码只读才是一切可信的基础。
规则 3 是价值体现的地方。那些 SUSPECT: 测试就是交付物。FINDINGS.md 在三周结束时有 31 个条目,其中 9 个是真实的 bug——包括一个舍入路径,少收了部分年费计划用户每张发票几分钱,这个 bug 自 2023 年以来一直在生产环境运行。
覆盖率告诉你一行代码执行了。它不告诉你如果这行代码有问题,有谁会注意到。在这个过程早期,我有一个文件达到了 94% 的覆盖率,但它的测试都是这样的:
it("computes the invoice", () => {
const result = computeInvoice(fixture);
expect(result).toBeDefined(); // 🙃
});
绿色。覆盖了。毫无价值。
所以门槛变成了变异测试(JS/TS 侧用 Stryker)。它把 > 改成 >=,删除语句,交换布尔值,然后问是否有任何测试失败。一个检测不到故意破坏的行的测试就不是测试。
# gate a single file, fail the loop under threshold
npx stryker run \
--mutate "src/billing/prorate.ts" \
--reporters json,progress \
--coverageAnalysis perTest
python3 - <<'PY'
import json, sys
r = json.load(open("reports/mutation/mutation.json"))
f = next(iter(r["files"].values()))
m = f["mutants"]
killed = sum(1 for x in m if x["status"] in ("Killed", "Timeout"))
score = 100 * killed / max(len(m), 1)
print(f"mutation score: {score:.1f}% ({killed}/{len(m)})")
sys.exit(0 if score >= 50 else 1)
PY
把幸存的变异体反馈给 agent——"这 14 个变异体存活了,它们是这样的,写能杀死它们的测试"——比"改进测试"有效得多。存活的变异体是一个具体的、可检查的待办清单,而 agent 最擅长的就是具体的可检查的待办清单。
三周,每天大约 2 小时 agent 时间配合我的正常工作(Claude Code CLI、Sonnet 5 做批量生成、Opus 5 做控制流复杂的文件):
$180 和三周兼职注意力,对比一个轻松两个月的手工项目。但注意表格中没有的那一行:我读了全部 1,200 个测试。那才是真正的成本。
覆盖率是虚荣指标;变异分数是诚实的那个。 6% → 71% 这个数字是我会在 standup 上说的。34% → 58% 这个数字告诉我是否可以周五下午做重构。如果你用覆盖率给 agent 设门槛,你会得到覆盖率,而且是以最便宜的方式得到——也就是 expect(result).toBeDefined()。
表征不等于正确,混淆两者是危险的。 补写的测试套件是生产的照片,包括 bug 在内。如果你不同样强制 agent 标记看起来有问题的部分,你会把四年的意外行为固定成一个可执行规范,失去将其称为 bug 的能力。SUSPECT: 前缀花了我 prompt 中一行,返回了 9 个真实 bug。
永远不要让 agent 编辑它正在测试的代码。 不是"劝阻"——而是从机械上禁止,并在每个 session 后 diff src/ 确认。这一条规则是安全网和一个非常自信的 intern 重写你的账单逻辑来让它的测试通过之间的区别。
运行时 traces 远胜源代码阅读。 源代码告诉 agent 作者的意思。真实的输入输出 JSONL 文件告诉它机器在做什么。对于 legacy 代码——两者几年前就分叉了——trace 是 ground truth,源代码是一份历史文档。
你的评审容量才是真正的吞吐量限制。 agent 4 分钟就能产生一个文件的测试。我需要 20 分钟评审它。我为加速生成所做的一切都是浪费;真正有帮助的是按风险等级批量评审——快速浏览纯函数测试,逐行细读涉及金钱的测试——并强制小而单一文件的 diffs,这样评审永远不需要热身时间。
两件事。首先,把变异门控接到 CI 中只针对变更文件,这样分数就不会悄悄再往下降——不被捍卫的测试套件衰减很快。其次,对隔壁的 Python 服务运行同样的 churn × darkness 排名,它有同样的疾病但爆炸半径更可怕。
我一直在绕的更长期的事情:traces 比测试更有价值。真实输入输出的记录语料库是让 agent 在 legacy 代码上有用的 artifact,几乎没有人费心去收集一个。
如果你正盯着一个你不敢改的代码库,不要从"添加测试"开始。从以下开始:
按 churn × uncovered 排名文件。
记录真实的输入和输出。
锁定行为,禁止源代码编辑,标记看起来有问题的部分。
用变异分数做门槛。
这个顺序就是大部分价值所在。Agent 只是让这件事变便宜了。
你有没有试过用 AI agent 补写测试?我想听听哪里做得很糟糕——尤其是如果你找到了控制评审时间的方法。写在评论区 👇
如果这有用,在 Dev.to 上关注我——我写我学到的关于构建自主编码设置的东西,大约每周一篇构建日志。如果你还没试过,Claude Code 就是我运行这一切的工具。🚀