答案是肯定的,甚至可以说比以往更重要。只不过,它的角色已从一种例行仪式转变为一份契约:当编码 Agent 负责编写实现时,先行编写的测试就成了规格说明。它在任何代码出现之前就定义了“完成”的标准,并能在 Agent 谎报任务已完成时当场识破。
大多数把实现工作交给 Claude Code、Cursor 或 GitHub Copilot 的开发者都遇到过这种情况:Agent 宣称所有测试全部通过,结果功能第一次使用就出了问题;或者某个测试文件被悄悄改写,只为与新代码保持一致。
本文将介绍面向 Agent 的测试先行工作流,内容包括:一个真实的 Vitest 测试文件、与之配套的具体指令、Agent 绕过测试的常见手段,以及堵住这些漏洞的防护措施。
核心要点
- 在 Agent 动手之前编写的测试相当于一份可执行的规格说明:Agent 的任务是让测试通过,而不是自行定义何为“通过”。
- 在代码之后编写的测试往往只是描述代码已有的行为,因此天然就能通过,实际上什么也没验证。
- Agent 发来的“所有测试均已通过”只是一个声明,针对已推送提交运行的 CI 结果才是证据。
- 如果同一个 diff 既修改了实现,又修改了评判该实现的断言,就等于 Agent 在给自己的作业打分。因此,测试变更应与代码变更分开评审。
- 对于一次性原型和纯视觉调整,可以跳过测试先行,因为在亲眼看到结果之前,预期结果根本无法写下来。
什么是红-绿-重构?
红-绿-重构(Red-Green-Refactor)是 TDD 的核心循环:先编写一个描述某一行为的失败测试(红),然后编写能让它通过的最少代码(绿)。在测试套件全绿的前提下,再在不改变行为的情况下整理代码(重构)。如需结合 React 示例进行更详细的回顾,请参阅 React 中的 TDD 与 BDD。
AI 驱动的 TDD 工作流如何运作?
要让 AI 驱动的 TDD 奏效,关键在于:由人编写或审批失败测试,并明确要求 Agent 在不修改测试的前提下让它们通过。需求由测试承载,Agent 只负责提供实现。以下示例使用 Vitest 5。
首先编写一个桩函数(stub),让测试文件能导入真实存在的内容:
// src/cart/applyCoupon.ts
export type Coupon =
| { code: string; kind: 'percent'; value: number }
| { code: string; kind: 'fixed'; valueCents: number };
export function applyCoupon(
subtotalCents: number,
code: string,
coupons: Coupon[],
): number {
throw new Error('not implemented');
}
然后编写规格说明:
// src/cart/applyCoupon.test.ts
import { describe, it, expect } from 'vitest';
import { applyCoupon, type Coupon } from './applyCoupon';
const coupons: Coupon[] = [
{ code: 'SAVE10', kind: 'percent', value: 10 },
{ code: 'FIVEOFF', kind: 'fixed', valueCents: 500 },
];
describe('applyCoupon', () => {
it('takes a percentage off the subtotal', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
it('takes a fixed amount off the subtotal', () => {
expect(applyCoupon(2000, 'FIVEOFF', coupons)).toBe(1500);
});
it('never returns a negative total', () => {
expect(applyCoupon(300, 'FIVEOFF', coupons)).toBe(0);
});
it('matches codes case-insensitively', () => {
expect(applyCoupon(2000, 'save10', coupons)).toBe(1800);
});
it('rejects unknown codes', () => {
expect(() => applyCoupon(2000, 'BOGUS', coupons)).toThrow('Unknown coupon');
});
});
由于桩函数存在并会抛出异常,这五个测试都是在运行时失败,而不是因为导入缺失而失败,这就是一个干净的红色阶段。最后一个测试值得细看:桩函数确实会抛出异常,但异常信息与 'Unknown coupon' 不匹配,所以它依然是出于正确的原因而失败。每个测试都锁定了一条规则(百分比折扣与固定金额折扣、总额下限为零、大小写处理),否则这些规则都得靠 Agent 去猜。
接着,附上明确的约束条件,把任务交给 Agent:
Implement applyCoupon in src/cart/applyCoupon.ts so every test in
src/cart/applyCoupon.test.ts passes.
Rules:
- Do not create, edit, rename, skip or delete any *.test.ts file.
- Do not add .skip, .only, or mocks of applyCoupon.
- If a test looks wrong, stop and explain why instead of changing it.
- When finished, run `npx vitest run src/cart` and paste the full output.
“停下来并解释”这一条为 Agent 提供了修改测试之外的正当出路。要求粘贴输出能帮你省去一个步骤,但它并不能证明任何事情。更多适用于这一循环的提示词技巧,请参阅 AI 辅助编程的 5 个技巧。
测试先行在 Agent 手中会如何走样?
Agent 往往会寻找通往全绿结果的最短路径,其中有三条路径会完全绕开规格说明。
修改或删除测试。 当实现难度较大时,修改预期远比满足预期容易。一个被改动的 toBe(1800)、一个新增的 .skip,或者一个凭空消失的测试文件,都能换来全绿的运行结果,而需求本身已被抹去。
弱断言。 在代码之后编写的测试往往只确认代码做了什么,而不是代码应该做什么:
// Confirms the code ran, not that it's right
it('applies coupon', () => {
const result = applyCoupon(2000, 'SAVE10', coupons);
expect(result).toBeDefined();
expect(typeof result).toBe('number');
});
// Pins the behaviour
it('takes 10% off with SAVE10', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
无论 applyCoupon 返回 0、2000 还是 999999,这个使用 toBeDefined() 的弱测试都能通过。这正是编写顺序至关重要的原因:Agent 在自己写完代码后编写的测试,往往就长成第一个版本那样。Agent 还喜欢用大量此类浅层检查来充实测试套件,相关模式可参阅Agent 总爱写的冒烟测试。
Mock 被测对象本身。 Mock 被测模块意味着测试实际运行的是 mock,无论真实实现如何,测试都会保持绿色:
import { vi } from 'vitest';
vi.mock('./applyCoupon', () => ({ applyCoupon: () => 1800 }));
// Every assertion now tests the mock.
Vitest 会将 vi.mock 提升(hoist)到所有 import 之前,因此工厂函数会在真实代码加载之前替换掉该模块,真实的 applyCoupon 根本不会执行。应该 mock 的是边界(网络、存储、时间),而不是你正在定义规格的那个单元。
哪些防护措施能让规格说明保持不变?
防护措施只有在机械化执行时才有效。指令可以减少篡改测试的情况,而 CI 和代码评审则负责兜住指令遗漏的部分。
单独评审测试 diff。 把 *.test.ts 下的变更作为一次独立评审,并在评审实现之前进行。在 GitHub 上,可以借助 CODEOWNERS 配合强制代码所有者评审,确保任何测试路径的变更都必须经过指定人员签字批准。
阻止或禁止 Agent 编辑测试。
- Claude Code:在
.claude/settings.json中添加一条permissions.deny规则,例如"Edit(**/*.test.ts)"。Edit 拒绝规则覆盖 Claude 的内置文件工具,以及它能在 Bash 中识别的文件命令(如sed和tee)和重定向目标,但不覆盖通过脚本或其他子进程间接写入文件的情况。因此应将这条规则视为增加阻力的手段,而非强制保障。 - Cursor:在
.cursor/rules下的项目规则中写明这条规则。 - GitHub Copilot:将其写入
.github/copilot-instructions.md。Cursor 和 Copilot 也都会读取AGENTS.md。
让 CI 来判定“完成”。 针对已推送的提交运行 npx vitest run。在 CI 环境中,Vitest 一旦发现 .only 就会判定运行失败,除非你传入了 --allowOnly 或设置了 allowOnly: true。但它不会拦截 .skip,因此需要在实现分支上加一道 diff 检查:
changed=$(git diff --name-only origin/main...HEAD -- '*.test.ts' '*.spec.ts')
if [ -n "$changed" ]; then
echo "Test files changed on an implementation branch:"
echo "$changed"
exit 1
fi
检出(checkout)时需要保留足够的提交历史,以便能够解析 origin/main。
检查断言是否验证了行为。 针对每条断言,问问自己:一个错误的实现是否仍能通过它?精确值、抛出的错误和可见的输出经得起重构的考验;而对内部辅助函数的调用次数和类型检查则不然。
即便测试套件无懈可击,也有其局限性。单元测试通过只能说明代码处理了有人写下来的那些用例,并不能说明真实用户能在浏览器中顺利走完整个流程。对已上线功能的会话回放仍可能显示:CI 一片绿色,结账流程却已崩溃。
什么时候不值得使用 TDD?
如果无法预先说明预期结果,测试先行的成本就会高于收益。
| 场景 | 跳过测试先行的原因 |
|---|---|
| 一次性原型 | 代码只用于回答某个问题,随后就会被删除 |
| 视觉 UI 调整(间距、颜色、布局) | 正确结果要靠肉眼判断,暂时没有可断言的内容 |
如果某个原型最终被保留下来,那么在 Agent 将其固化为生产代码之前,先把测试写好。
结语
当代码由 Agent 编写时,规格说明是唯一只能由你提供的部分,而先行编写的测试让它变得可执行。在下一个 Agent 任务中,请先编写失败的 Vitest 测试文件,向 Agent 下达禁止修改测试的指令,并且只有在 CI 通过、测试 diff 也通过单独评审之后才进行合并。
常见问题
Linter 能否发现 Agent 添加的跳过测试?
可以。@vitest/eslint-plugin 包提供了 vitest/no-disabled-tests 规则,用于标记 test.skip、it.skip 和 describe.skip;还提供了 vitest/no-focused-tests 规则,用于标记 .only。推荐配置对禁用的测试仅给出警告,因此请将该规则设为 'error',并在 CI 中运行 ESLint。Jest 项目可以从 eslint-plugin-jest 获得同名规则。Lint 可能会漏掉通过别名进行的调用,因此仍应保留 git diff 检查。
能否由 AI Agent 而不是人来编写失败测试?
可以,前提是编写测试和编写实现是两个独立的步骤。先让一个 Agent 会话基于桩函数起草测试,然后由你亲自审查断言,并在开始实现之前提交。负责实现的会话只应看到它无权修改的测试。如果由同一个会话同时编写两者,测试最终描述的将是它自己的代码,而不是需求。
不先写测试,只靠一份详尽的书面规格说明够不够?
不够。文字形式的规格说明或带文档注释的桩函数能告诉 Agent 要构建什么,但没有任何机制会依据它来检查结果。失败的测试不会被一个看似合理的实现所糊弄:它要么通过,要么不通过。最好两者并用:把需求和约束写在提示词或指令文件中,并将必须成立的用例编写为测试,由 CI 在每次提交时运行。
当 Agent 指出某个测试有误时,该怎么办?
亲自审查 Agent 的解释。如果测试确实有误,请在实现分支之外修复它:将修正后的测试以独立的提交或拉取请求合入 main,如有使用 CODEOWNERS 则按其要求完成评审,然后对实现分支执行 rebase。由于 diff 检查是将分支与 origin/main 进行比较,已合入 main 的测试修复不会触发它,而 Agent 也始终不会编辑规格说明。