Use Cases (更新: 2026/6/2)

Claude Code TDD实践指南:用Vitest和node:test做测试驱动开发

用Claude Code实践TDD:包含可运行的Vitest、node:test、CI、hooks和提示词模板。

Claude Code TDD实践指南:用Vitest和node:test做测试驱动开发

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 referenceClaude Code memoryClaude Code settingsVitest Getting StartedVitest CLINode.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:
- 只抽出重复的日期比较逻辑。

返回:
- 测试名、失败日志、修改文件、执行命令、剩余风险。

更多测试设计可以继续阅读API测试指南测试策略指南

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的速度变成可审查、可回滚差异的安全框架。

#Claude Code #TDD #test-driven development #testing #quality assurance
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。