Claude Code TDD Guide: Test-Driven Development with Vitest and node:test
Practice TDD with Claude Code using working Vitest and node:test examples, CI, hooks, and prompt templates.
Claude Code can produce working code quickly, but speed alone does not make a change safe. The risky part usually appears later: a misunderstood edge case, a broken regression test, a discount bug, or a CI failure that was never reproduced locally.
Test-driven development, or TDD, is a practical way to keep that speed under control. The loop is simple: write a failing test first, make the smallest implementation pass, then refactor without changing behavior. This is the Red-Green-Refactor cycle. Red means the test fails for the right reason. Green means the behavior is implemented. Refactor means the code is cleaned up while the test suite stays green.
Claude Code fits this workflow well because it can enumerate cases, read failing output, implement the smallest fix, and update CI. The key is to avoid asking it to “just build the feature.” Ask it to prove the failure first. This article uses copy-pasteable Vitest and node:test examples, current Claude Code hook syntax, CI configuration, and prompt templates you can reuse.
The references checked for this update were the official Claude Code hooks reference, Claude Code memory, Claude Code settings, Vitest Getting Started, Vitest CLI, and the Node.js test runner. The hook example below reads JSON from stdin, matching the current Claude Code docs instead of older environment-variable-only snippets.
Where Claude Code Helps in TDD
Claude Code is useful for generating test cases, writing the first implementation, interpreting failures, adding CI, and summarizing residual risk. Humans should still decide the business rules, security boundaries, public API contract, and release decision.
| Step | Claude Code task | Human review |
|---|---|---|
| Red | Write failing tests from the spec | Did it invent requirements? |
| Green | Implement the smallest passing change | Did it add unnecessary abstractions? |
| Refactor | Remove duplication and improve names | Did behavior remain unchanged? |
| CI | Run tests on every PR | Is the Node version realistic? |
| Operations | Use hooks and CLAUDE.md | Is automation fast enough? |
flowchart LR
A["Slice the spec"] --> B["Red: failing test"]
B --> C["Green: smallest implementation"]
C --> D["Refactor: clean up"]
D --> E["CI and hooks rerun tests"]
E --> B
Example 1: Red-Green-Refactor with Vitest
Pricing logic is a good first TDD target because small mistakes affect revenue directly. Install Vitest first.
npm install -D vitest
{
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^3.0.0"
}
}
Create src/cart.test.ts before the implementation exists.
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");
});
});
Ask Claude Code to prove Red before it writes src/cart.ts.
We are in the Red step. src/cart.test.ts exists, but src/cart.ts does not.
Please:
1. Run npm test and confirm the failure.
2. Implement only the smallest src/cart.ts needed to pass.
3. Do not add UI, DB calls, external APIs, or future features.
4. Refactor only after the targeted tests are green.
Then use this implementation.
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));
}
Example 2: Boundary Values with node:test
For small Node utilities, the built-in test runner keeps dependencies low. Save this as limit.test.mjs and run it with node --test limit.test.mjs.
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
Example 3: Regression Tests for an API Bug
When a production bug appears, turn it into a failing test before changing the implementation.
Add a regression test using TDD.
Background:
- POST /checkout incorrectly accepts expired coupons.
- Valid coupons and no-coupon checkout must continue to work.
Red:
- Add a test that expects 400 for an expired coupon.
- Confirm the current implementation fails that test.
Green:
- Make the smallest API change needed to pass.
Refactor:
- Extract only duplicated date comparison logic.
Return:
- Test name, failing output, changed files, commands run, remaining risk.
For broader testing patterns, see the API testing guide and the testing strategy guide.
CI and Hooks
CI keeps the loop honest.
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 can run related tests after edits. Current command hooks receive JSON on stdin, so the script reads tool_input.file_path.
{
"$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);
Keep this hook small. Run related tests on edit, then leave slow E2E suites to CI. For deeper setup patterns, read the hooks guide and the CLAUDE.md best practices.
Prompt Templates
New feature TDD:
Goal:
Add [feature].
Spec:
- [happy path]
- [boundary values]
- [failure behavior]
Process:
1. Write tests first.
2. Run npm test and show Red.
3. Implement the smallest Green change.
4. Refactor without changing behavior.
Return:
Failure output, commands, changed files, remaining risk.
Bug fix TDD:
Reproduction:
[input, user action, or log]
Expected:
[correct behavior]
Current:
[actual behavior]
Request:
Add a failing regression test first.
Then make the smallest fix.
Do not weaken or delete existing tests without explaining why.
Refactor with safety:
Target:
[file/function]
Constraint:
Public behavior must not change.
Steps:
1. Add characterization tests for current behavior.
2. Confirm they are green.
3. Refactor internals only.
4. Re-run the same tests.
Common Pitfalls
The first pitfall is skipping Red. If the test passes before the fix, it is not protecting anything. The second is testing implementation details instead of behavior. For pricing, totals and errors matter more than private helper names. The third is using real time inside tests. Inject now so expired coupons do not become flaky next month. The fourth is trusting mocks too much; payment, email, and CRM flows need contract or staging checks. The fifth is allowing Claude Code to delete tests just to get Green.
CTA
Start with one pricing rule, CLI parser, or API regression. Keep the proof small and repeatable. Solo builders can use the free Claude Code cheatsheet and the templates above. If you want reusable prompts, hooks, and review checklists, browse ClaudeCodeLab products. Teams that need TDD, CI, permissions, and review workflows adapted to a real repository can use Claude Code training and consultation.
What Happened When We Tried This
In Masa’s workflow, asking Claude Code for the failing test first reduced review time more than asking for implementation first. Coupon expiry, zero quantity, and unauthenticated API paths surfaced earlier. Running every test from a hook was too slow, so the practical setup became related Vitest tests after edits and full E2E in CI. TDD worked best as a lightweight guardrail for Claude Code’s speed, not as a ceremony.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.