Red-Green-Refactor (TDD)
Build every feature, fix, and migration through a strict test-first loop. Write one failing test, make it pass with minimal code, then clean up. Never write implementation before a test exists for it.
Default test runner: Vitest. All examples use vitest imports. Use bun test if running in Bun (API-compatible).
When to use this skill
- Creating a new feature (write tests for the next behavior)
- Fixing a bug (write a test that reproduces it first)
- Migrating or refactoring logic (write tests that lock current behavior, then swap)
Output
Each TDD cycle produces:
| File | Purpose |
|---|---|
*.test.ts |
One or more test cases proving the behavior exists |
*.ts |
The minimal implementation that satisfies all tests |
The cycle is complete when all tests pass and the acceptance checklist (Phase 4) is satisfied.
The Loop
Phase 1: Red — Prove the behavior doesn't exist
Write one test for the next small piece of behavior. Run it. It must fail.
// user-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { createUser } from "./user-service.js";
describe("createUser", () => {
it("returns the created user with an ID", async () => {
const db = { insert: vi.fn().mockResolvedValue({ id: "u1", email: "a@b.com" }) };
const user = await createUser(db, { email: "a@b.com", name: "Alice" });
expect(user.id).toBe("u1");
// Fails: createUser does not exist yet
});
});
Verify the failure is real: The error should be Cannot find module './user-service.js' or createUser is not a function — a missing-logic error, not a config error. If the failure is about missing imports, broken paths, or TypeScript config, fix that first before proceeding.
Phase 2: Green — Make it pass with minimal code
Write the simplest implementation that satisfies the test. Do not build for the future.
// user-service.ts
export async function createUser(db: any, input: { email: string; name: string }) {
return await db.insert(input);
}
Run the test. It must pass. This is the "proof of work" — the transition from Red to Green.
Phase 3: Refactor — Clean up while staying Green
Improve types, naming, and structure. Run tests after every change. If they go Red, revert immediately.
// user-service.ts
interface Database {
insert(data: Record<string, unknown>): Promise<{ id: string; email: string }>;
}
interface CreateUserInput {
email: string;
name: string;
}
export async function createUser(db: Database, input: CreateUserInput) {
return await db.insert({ email: input.email, name: input.name });
}
Phase 4: Accept — Verify the cycle is done
Before starting the next cycle, check:
- All tests pass
- The test name describes the behavior, not the implementation (e.g., "returns created user" not "calls db.insert")
- No implementation exists without a corresponding test
- No test was modified to make a failing implementation pass
- Types are explicit — no
anyremaining from Phase 2
If any item fails, fix it before starting the next Red phase.
Workflows by Task Type
New Feature
Follow the loop above. Each cycle adds one behavior:
it("returns created user with an ID")→ implementcreateUserit("rejects duplicate emails")→ add duplicate checkit("validates email format")→ add validation
Termination: The feature is done when all acceptance criteria from the task have a corresponding passing test.
Bug Fix
Start by reproducing the bug as a failing test, then fix it:
- Red: Write a test that exercises the exact bug scenario. It must fail (proving the bug exists).
- Green: Fix the implementation. The test passes.
- Refactor: Clean up if needed.
// Reproducing a bug: off-by-one in pagination
it("returns page 2 starting at offset 10, not 11", async () => {
const result = await paginate({ page: 2, pageSize: 10 });
expect(result.offset).toBe(10); // Was returning 11 — the bug
});
Termination: The reproduction test passes and no existing tests are broken.
Migration / Refactoring
Lock current behavior with tests first, then swap the implementation:
- Red: Write tests that assert the current output/behavior of the existing code. They should pass immediately (they're documenting what exists).
- Swap: Replace the implementation (new framework, new library, new architecture).
- Green: Run the tests. Fix the new implementation until all tests pass.
// Locking current behavior before migrating from Commander to Citty
it("list command exports a valid command definition", async () => {
const mod = await import("./commands/list.js");
expect(mod.default).toBeDefined();
expect(mod.default.meta.name).toBe("list");
});
Termination: All pre-migration tests pass against the new implementation.
Core Rules
1. No Horizontal Splurging
Write one test at a time. Never write multiple tests before implementing any of them.
✅ Write 1 test → See it fail → Write 1 fix → See it pass → Repeat
❌ Write 5 tests → Write all implementations → Hope they pass
2. Backpressure Through Types
Use TypeScript's type system to make invalid states unrepresentable. This prevents the implementation from "drifting" into incorrect but test-passing code.
// Bad: any allows silent bugs
export async function createUser(db: any, input: any) { ... }
// Good: types enforce the contract — compiler catches misuse
interface Database {
insert(data: CreateUserInput): Promise<User>;
}
export async function createUser(db: Database, input: CreateUserInput): Promise<User> { ... }
3. Never Modify Tests to Fit Implementation
If a test must change, it's because the requirement changed, not because the code is difficult to write. If you're tempted to relax an assertion, stop and fix the implementation instead.
4. Name Tests for Behavior, Not Implementation
// Bad: coupled to implementation details
describe("UserService.insertIntoPostgres", () => { ... });
// Good: describes what the user gets
describe("createUser", () => {
it("returns the created user with an ID", ...);
it("rejects duplicate email addresses", ...);
});
Gotchas
- Hardcoded absolute paths in tests: Using
/Users/you/project/...breaks CI and other machines. Always usepath.resolve(".")orimport.meta.urlfor relative resolution. - Dead side-effect validation: Calling a validation function but ignoring its return value. The parsed result must be the only source of truth for the rest of the execution. Parse, don't validate.
- Shadowed type noise: Suppressing TypeScript errors with
@ts-ignoreinstead of fixing the type root config. Create atsconfig.jsonin your test directory to includevitesttypes cleanly. - Testing implementation, not behavior: If renaming an internal function breaks your tests, the tests are too tightly coupled. Test the public contract.
- Forgetting the refactor phase: Going Red → Green → Red → Green without ever cleaning up Phase 2's
anytypes and minimal code. Schedule refactor time explicitly.
Related Skills
- typed-service-contract — Defines the Spec & Handler architecture. Use TDD to build each vertical slice: write contract tests (spec) first, then logic tests (handler), following the Red-Green-Refactor loop for each.
Reference Materials
- references/cli-testing-patterns.md — Read when applying TDD to CLI commands. Covers behavior-centric test naming, the testing pyramid for CLIs (unit → integration → interactive), and framework migration patterns.