12k
All articles

当代码由 Agent 编写时,TDD 还重要吗?

了解如何用测试驱动开发约束AI编程代理:先编写Vitest测试,防止测试被篡改,并通过CI和代码审查验证实现。

OpenReplay Team
OpenReplay Team
当代码由 Agent 编写时,TDD 还重要吗?

答案是肯定的,甚至可以说比以往更重要。只不过,它的角色已从一种例行仪式转变为一份契约:当编码 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 也始终不会编辑规格说明。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.