Claude Code TDD实践指南:用Vitest和node:test做测试驱动开发
用Claude Code实践TDD:包含可运行的Vitest、node:test、CI、hooks和提示词模板。
Claude Code可以很快写出代码,但速度不等于质量。真正容易出问题的地方,往往是在功能完成之后:边界值遗漏、旧行为被破坏、折扣金额算错、CI环境才失败,或者测试只是为了配合实现而补上的。
TDD,也就是测试驱动开发,是让Claude Code更可靠的一种工作方式。它的循环很简单:先写一个应该失败的测试,再写最小实现让测试通过,最后在不改变行为的前提下重构。这个循环常被称为Red-Green-Refactor。Red是失败,Green是通过,Refactor是整理代码。
本文面向已经开始使用Claude Code、但还不确定如何写测试的开发者。我们会用可复制运行的Vitest和node:test示例,展示新功能、CLI工具、API回归测试、CI、Claude Code hooks和提示词模板。这里的重点不是“让AI多写测试”,而是先把验收标准固定下来,再让Claude Code安全地加速。
本文更新时参考了官方Claude Code hooks reference、Claude Code memory、Claude Code settings、Vitest Getting Started、Vitest CLI和Node.js test runner。尤其是hooks示例,使用当前文档中的JSON stdin方式读取tool_input.file_path,不再依赖旧文章里常见的单一环境变量写法。
Claude Code在TDD中的位置
Claude Code适合做测试用例枚举、失败日志解读、最小实现、CI补充和风险总结。人类仍然要决定业务规则、公开API、付款和权限边界,以及是否发布。
| 阶段 | 交给Claude Code | 人类要检查 |
|---|---|---|
| Red | 根据规格写失败测试 | 是否擅自增加了需求 |
| Green | 写最小实现 | 是否引入了多余抽象 |
| Refactor | 去重、改名、整理结构 | 行为是否保持不变 |
| CI | 在PR中运行测试 | Node版本和命令是否真实 |
| 运维 | 用hooks和CLAUDE.md固化习惯 | 自动化是否太慢 |
flowchart LR
A["拆小规格"] --> B["Red:失败测试"]
B --> C["Green:最小实现"]
C --> D["Refactor:整理代码"]
D --> E["CI和hooks重新运行"]
E --> B
实例1:用Vitest测试价格和优惠券
价格计算、优惠券、订阅套餐这类逻辑非常适合TDD,因为一个边界值错误就可能影响收入。先安装Vitest。
npm install -D vitest
{
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^3.0.0"
}
}
先创建src/cart.test.ts,此时src/cart.ts还不存在。
import { describe, expect, it } from "vitest";
import { priceCart, ValidationError } from "./cart";
describe("priceCart", () => {
it("calculates subtotal and total without a coupon", () => {
const result = priceCart({
items: [
{ sku: "book", unitPriceCents: 1200, quantity: 2 },
{ sku: "video", unitPriceCents: 3000, quantity: 1 },
],
});
expect(result).toEqual({
subtotalCents: 5400,
discountCents: 0,
totalCents: 5400,
});
});
it("applies a valid percent coupon", () => {
const result = priceCart(
{
items: [{ sku: "course", unitPriceCents: 10000, quantity: 1 }],
coupon: {
code: "SPRING20",
percentOff: 20,
expiresAt: "2026-06-30T00:00:00.000Z",
},
},
{ now: new Date("2026-06-02T00:00:00.000Z") },
);
expect(result.totalCents).toBe(8000);
expect(result.discountCents).toBe(2000);
});
it("rejects expired coupons", () => {
expect(() =>
priceCart(
{
items: [{ sku: "course", unitPriceCents: 10000, quantity: 1 }],
coupon: {
code: "OLD20",
percentOff: 20,
expiresAt: "2026-05-01T00:00:00.000Z",
},
},
{ now: new Date("2026-06-02T00:00:00.000Z") },
),
).toThrow(ValidationError);
});
it("rejects zero or negative quantity", () => {
expect(() =>
priceCart({
items: [{ sku: "book", unitPriceCents: 1200, quantity: 0 }],
}),
).toThrow("quantity must be positive");
});
});
把Red阶段明确交给Claude Code。
现在是Red阶段。src/cart.test.ts已经存在,但src/cart.ts还没有。
请按顺序执行:
1. 运行npm test,确认测试失败。
2. 只实现让测试通过所需的最小src/cart.ts。
3. 不要添加UI、数据库、外部API或未来功能。
4. 目标测试Green之后,再做不改变行为的重构。
src/cart.ts的最小实现如下。
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
type CartItem = {
sku: string;
unitPriceCents: number;
quantity: number;
};
type Coupon = {
code: string;
percentOff: number;
expiresAt: string;
};
type CartInput = {
items: CartItem[];
coupon?: Coupon;
};
type PriceOptions = {
now?: Date;
};
export function priceCart(input: CartInput, options: PriceOptions = {}) {
if (input.items.length === 0) {
throw new ValidationError("cart must contain at least one item");
}
const subtotalCents = input.items.reduce((sum, item) => {
if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
throw new ValidationError("quantity must be positive");
}
if (!Number.isInteger(item.unitPriceCents) || item.unitPriceCents < 0) {
throw new ValidationError("unitPriceCents must be a non-negative integer");
}
return sum + item.unitPriceCents * item.quantity;
}, 0);
const discountCents = calculateDiscount(subtotalCents, input.coupon, options.now ?? new Date());
return {
subtotalCents,
discountCents,
totalCents: subtotalCents - discountCents,
};
}
function calculateDiscount(subtotalCents: number, coupon: Coupon | undefined, now: Date) {
if (!coupon) return 0;
if (coupon.percentOff <= 0 || coupon.percentOff > 100) {
throw new ValidationError("percentOff must be between 1 and 100");
}
if (new Date(coupon.expiresAt).getTime() < now.getTime()) {
throw new ValidationError("coupon expired");
}
return Math.round(subtotalCents * (coupon.percentOff / 100));
}
实例2:用node:test测试CLI边界值
第二个场景是CLI参数或小工具。标准库的node:test不需要额外测试框架,适合轻量Node项目。
import test from "node:test";
import assert from "node:assert/strict";
export function parseLimit(value, fallback = 20) {
if (value === undefined || value === "") return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new TypeError("limit must be an integer");
}
if (parsed < 1 || parsed > 100) {
throw new RangeError("limit must be between 1 and 100");
}
return parsed;
}
test("parseLimit uses fallback when the value is empty", () => {
assert.equal(parseLimit(undefined), 20);
assert.equal(parseLimit("", 50), 50);
});
test("parseLimit accepts values from 1 to 100", () => {
assert.equal(parseLimit("1"), 1);
assert.equal(parseLimit("100"), 100);
});
test("parseLimit rejects decimals and out-of-range values", () => {
assert.throws(() => parseLimit("1.5"), /integer/);
assert.throws(() => parseLimit("0"), /between 1 and 100/);
assert.throws(() => parseLimit("101"), /between 1 and 100/);
});
node --test limit.test.mjs
实例3:把API事故固定成回归测试
第三个场景是API bug。比如POST /checkout错误地接受了过期优惠券,先写会失败的回归测试,再修实现。
请用TDD追加一个API回归测试。
背景:
- POST /checkout错误地接受过期优惠券。
- 合法优惠券和不使用优惠券的购买不能被破坏。
Red:
- 先追加一个过期优惠券应返回400的测试。
- 确认当前实现会失败。
Green:
- 用最小API修改让测试通过。
Refactor:
- 只抽出重复的日期比较逻辑。
返回:
- 测试名、失败日志、修改文件、执行命令、剩余风险。
CI、hooks和CLAUDE.md
CI配置要简单明确。
name: test
on:
pull_request:
push:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Claude Code hooks可以在编辑后运行相关测试。
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/run-related-vitest.mjs",
"timeout": 120
}
]
}
]
}
}
import { spawnSync } from "node:child_process";
import path from "node:path";
let raw = "";
for await (const chunk of process.stdin) {
raw += chunk;
}
const event = raw ? JSON.parse(raw) : {};
const filePath = event.tool_input?.file_path;
if (typeof filePath !== "string" || !/\.[cm]?[jt]sx?$/.test(filePath)) {
process.exit(0);
}
const target = path.isAbsolute(filePath)
? path.relative(process.cwd(), filePath)
: filePath;
const result = spawnSync("npx", ["vitest", "related", target, "--run"], {
stdio: "inherit",
shell: process.platform === "win32",
});
process.exit(result.status ?? 1);
CLAUDE.md中可以写短规则,避免每次重复说明。
## TDD workflow
- Behavior changes start with a failing test.
- Show the Red result before implementation.
- Implement the smallest change that makes the test pass.
- Refactor only after the targeted test is Green.
- Report the command, result, changed files, and remaining risk.
hooks不要太重。编辑后跑相关Vitest即可,完整E2E留给CI。更多设置可看hooks指南和CLAUDE.md最佳实践。
Claude Code提示词模板
新功能TDD:
目标:
添加[功能名]。
规格:
- [正常路径]
- [边界值]
- [失败时行为]
流程:
1. 先写测试。
2. 运行npm test并展示Red。
3. 写最小Green实现。
4. 在不改变行为的前提下重构。
返回:
失败日志、命令、修改文件、剩余风险。
Bug修复TDD:
复现:
[输入、操作或日志]
期望:
[正确行为]
当前:
[实际行为]
请求:
先追加失败的回归测试。
然后做最小修复。
不要在未说明理由的情况下削弱或删除旧测试。
安全重构:
目标:
[文件/函数]
约束:
对外行为不能改变。
步骤:
1. 用characterization test固定当前行为。
2. 确认测试为Green。
3. 只整理内部结构。
4. 重新运行同一组测试。
常见陷阱
第一,跳过Red。测试如果一开始就通过,它可能什么也保护不了。第二,测试实现细节而不是行为。价格逻辑应该检查总价、折扣和错误,而不是私有函数名。第三,不固定时间。测试中直接使用new Date()会导致下个月突然失败。第四,过度相信mock。支付、邮件、CRM等收入链路还需要契约测试或预发布环境验证。第五,让Claude Code为了Green删除测试。删除或改期待值必须先解释理由。
转化引导(CTA)
可以从一个价格规则、一个CLI参数、一个API回归bug开始。个人开发者可以先用免费Claude Code速查表和本文模板。需要可复用提示词、hooks和审查清单时,可以查看ClaudeCodeLab产品。团队要把TDD、CI、权限和代码审查放进真实仓库,可以使用Claude Code培训与咨询。
实际试用结果
Masa实际使用后发现,让Claude Code先展示失败测试,比直接让它实现功能更节省审查时间。过期优惠券、0数量、未认证API这类边界问题更早暴露。另一方面,编辑后运行全部测试太慢,最终采用“PostToolUse只跑相关Vitest,完整E2E交给CI”的做法。TDD不是形式主义,而是把Claude Code的速度变成可审查、可回滚差异的安全框架。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。