Test-Driven Development with Claude Code: Practical Guide
Master TDD with Claude Code assistance. Learn to write tests first, let AI generate implementations, and build a reliable test-first workflow.
Master TDD with Claude Code assistance. Learn to write tests first, let AI generate implementations, and build a reliable test-first workflow.
Test-Driven Development has a reputation problem. Developers know it produces better code, but the upfront investment feels painful. Writing tests for code that doesn't exist? It feels backward.
Claude Code changes this equation. When AI can generate implementation from tests, TDD becomes genuinely faster than code-first development. You describe what you want through tests, Claude makes it work.
This guide shows you how to build a TDD workflow with Claude Code that's faster and more reliable than traditional development.
Classic TDD follows the Red-Green-Refactor cycle:
The problem? Step 2 can take a long time. You know what you want, but implementing it requires thought, debugging, and iteration.
With Claude Code, the cycle becomes:
The test becomes your specification. Claude turns specification into implementation. Your job shifts from "write code" to "define behavior."
Organize your project for TDD:
project/
├── src/
│ └── (implementation files)
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── .claude/
│ └── commands/
│ ├── test.md
│ ├── tdd.md
│ └── coverage.md
└── package.json / pytest.ini / etc.
.claude/commands/tdd.md:
---
description: TDD workflow - generate implementation from tests
arguments:
- name: test_file
description: Path to the test file
required: true
---
# Test-Driven Development Workflow
When the user runs /tdd [test_file], follow TDD methodology to generate implementation.
## Phase 1: Understand the Tests
1. Read the test file completely
2. Identify what's being tested:
- Class/function names
- Expected behaviors
- Edge cases covered
- Error conditions
3. Note the testing framework (Jest, Pytest, Mocha, etc.)
## Phase 2: Run Tests (Expect Failures)
1. Execute the tests: appropriate command for the framework
2. Confirm tests fail (Red phase)
3. Note specific failure messages
## Phase 3: Determine Implementation Location
Check if implementation file exists:
- If yes, read it to understand existing code
- If no, determine where it should be created
Follow project conventions for file placement.
## Phase 4: Generate Implementation
Write the minimal code to make tests pass:
1. Create stubs for all required functions/classes
2. Implement logic to satisfy each test
3. Handle edge cases that tests cover
4. DO NOT add functionality not covered by tests
## Phase 5: Run Tests (Expect Pass)
1. Execute tests again
2. If all pass, move to refactor phase
3. If any fail:
- Analyze the failure
- Fix the implementation
- Re-run tests
- Repeat until green
## Phase 6: Refactor
After tests pass, improve the code:
1. Remove duplication
2. Improve naming
3. Simplify complex logic
4. Add type hints (if applicable)
5. Run tests after each change to ensure still passing
## Output Format
Phase 1: Test Analysis
Phase 2: Initial Test Run
Phase 3: Implementation
Phase 4: Verification
Phase 5: Refactor
Implementation complete!
.claude/commands/test.md:
---
description: Run tests with smart detection
arguments:
- name: path
description: Optional path to specific test file or directory
required: false
- name: watch
description: Run in watch mode
required: false
---
# Smart Test Runner
Run tests intelligently based on project configuration.
## Detect Test Framework
Check for:
- package.json scripts (npm test, vitest, jest)
- pytest.ini, setup.cfg, pyproject.toml (pytest)
- Cargo.toml (cargo test)
- go.mod (go test)
## Run Appropriate Command
Based on detection:
- JavaScript/TypeScript: `npm test` or framework directly
- Python: `pytest`
- Rust: `cargo test`
- Go: `go test ./...`
If path is provided, run only those tests.
If watch flag is set, add watch mode flag.
## Output
Show test results with clear pass/fail indicators.
If failures, show:
- Which tests failed
- Expected vs actual
- Stack trace if helpful
Let's walk through a complete TDD session. We'll build a UserService from scratch.
Create tests/unit/UserService.test.ts:
import { UserService, User } from '../../src/services/UserService';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
describe('createUser', () => {
it('should create a user with valid data', () => {
const user = service.createUser({
name: 'John Doe',
email: 'john@example.com'
});
expect(user.id).toBeDefined();
expect(user.name).toBe('John Doe');
expect(user.email).toBe('john@example.com');
expect(user.createdAt).toBeInstanceOf(Date);
});
it('should throw error for empty name', () => {
expect(() => {
service.createUser({ name: '', email: 'john@example.com' });
}).toThrow('Name is required');
});
it('should throw error for invalid email', () => {
expect(() => {
service.createUser({ name: 'John', email: 'not-an-email' });
}).toThrow('Invalid email format');
});
it('should throw error for duplicate email', () => {
service.createUser({ name: 'John', email: 'john@example.com' });
expect(() => {
service.createUser({ name: 'Jane', email: 'john@example.com' });
}).toThrow('Email already exists');
});
});
describe('getUser', () => {
it('should return user by id', () => {
const created = service.createUser({
name: 'John Doe',
email: 'john@example.com'
});
const found = service.getUser(created.id);
expect(found).toEqual(created);
});
it('should return null for non-existent user', () => {
const found = service.getUser('non-existent-id');
expect(found).toBeNull();
});
});
describe('updateUser', () => {
it('should update user properties', () => {
const created = service.createUser({
name: 'John Doe',
email: 'john@example.com'
});
const updated = service.updateUser(created.id, { name: 'Jane Doe' });
expect(updated.name).toBe('Jane Doe');
expect(updated.email).toBe('john@example.com');
});
it('should throw error for non-existent user', () => {
expect(() => {
service.updateUser('bad-id', { name: 'New Name' });
}).toThrow('User not found');
});
});
describe('deleteUser', () => {
it('should delete existing user', () => {
const created = service.createUser({
name: 'John Doe',
email: 'john@example.com'
});
service.deleteUser(created.id);
expect(service.getUser(created.id)).toBeNull();
});
it('should throw error for non-existent user', () => {
expect(() => {
service.deleteUser('bad-id');
}).toThrow('User not found');
});
});
});
/tdd tests/unit/UserService.test.ts
Claude analyzes the tests and generates src/services/UserService.ts:
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
export interface CreateUserInput {
name: string;
email: string;
}
export interface UpdateUserInput {
name?: string;
email?: string;
}
export class UserService {
private users: Map<string, User> = new Map();
private emailIndex: Set<string> = new Set();
createUser(input: CreateUserInput): User {
// Validation
if (!input.name || input.name.trim() === '') {
throw new Error('Name is required');
}
if (!this.isValidEmail(input.email)) {
throw new Error('Invalid email format');
}
if (this.emailIndex.has(input.email.toLowerCase())) {
throw new Error('Email already exists');
}
// Create user
const user: User = {
id: this.generateId(),
name: input.name,
email: input.email,
createdAt: new Date(),
};
this.users.set(user.id, user);
this.emailIndex.add(input.email.toLowerCase());
return user;
}
getUser(id: string): User | null {
return this.users.get(id) || null;
}
updateUser(id: string, input: UpdateUserInput): User {
const user = this.users.get(id);
if (!user) {
throw new Error('User not found');
}
const updated: User = {
...user,
...input,
};
this.users.set(id, updated);
return updated;
}
deleteUser(id: string): void {
const user = this.users.get(id);
if (!user) {
throw new Error('User not found');
}
this.emailIndex.delete(user.email.toLowerCase());
this.users.delete(id);
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
private generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
}
/test tests/unit/UserService.test.ts
All 9 tests pass. The implementation matches the specification.
Start with basic tests, add complexity incrementally:
// First iteration: basic functionality
describe('Calculator', () => {
it('should add two numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
});
// Run /tdd, get basic implementation
// Second iteration: edge cases
describe('Calculator', () => {
it('should add two numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(calc.add(-2, 3)).toBe(1);
});
it('should handle decimals', () => {
expect(calc.add(0.1, 0.2)).toBeCloseTo(0.3);
});
});
// Run /tdd again, implementation improves
Define interfaces in tests before implementation:
// Define the interface you want
interface PaymentGateway {
processPayment(amount: number, currency: string): Promise<PaymentResult>;
refund(transactionId: string): Promise<RefundResult>;
getBalance(): Promise<number>;
}
// Write tests against the interface
describe('StripeGateway', () => {
let gateway: PaymentGateway;
it('should process payment successfully', async () => {
const result = await gateway.processPayment(100, 'USD');
expect(result.success).toBe(true);
expect(result.transactionId).toBeDefined();
});
});
Use table-driven tests for comprehensive coverage:
describe('PriceCalculator', () => {
const testCases = [
{ basePrice: 100, discount: 0, tax: 0.1, expected: 110 },
{ basePrice: 100, discount: 10, tax: 0.1, expected: 99 },
{ basePrice: 100, discount: 50, tax: 0, expected: 50 },
{ basePrice: 0, discount: 0, tax: 0.1, expected: 0 },
];
testCases.forEach(({ basePrice, discount, tax, expected }) => {
it(`calculates price: base=${basePrice}, discount=${discount}, tax=${tax}`, () => {
expect(calculator.calculate(basePrice, discount, tax)).toBe(expected);
});
});
});
Claude sees all cases and implements logic that handles them all.
Use mocks to define external dependencies:
describe('OrderService', () => {
let orderService: OrderService;
let mockPaymentGateway: jest.Mocked<PaymentGateway>;
let mockInventory: jest.Mocked<InventoryService>;
beforeEach(() => {
mockPaymentGateway = {
processPayment: jest.fn(),
};
mockInventory = {
checkStock: jest.fn(),
reserve: jest.fn(),
};
orderService = new OrderService(mockPaymentGateway, mockInventory);
});
it('should create order when stock available and payment succeeds', async () => {
mockInventory.checkStock.mockResolvedValue(true);
mockInventory.reserve.mockResolvedValue(true);
mockPaymentGateway.processPayment.mockResolvedValue({ success: true });
const order = await orderService.createOrder(items, paymentInfo);
expect(order.status).toBe('confirmed');
expect(mockInventory.reserve).toHaveBeenCalled();
expect(mockPaymentGateway.processPayment).toHaveBeenCalled();
});
});
The mocks define how OrderService should interact with dependencies.
.claude/commands/coverage.md:
---
description: Analyze test coverage and suggest missing tests
arguments:
- name: path
description: Path to analyze
required: false
---
# Test Coverage Analysis
When the user runs /coverage, analyze test coverage and suggest improvements.
## Run Coverage
1. Detect test framework
2. Run with coverage:
- Jest: `npm test -- --coverage`
- Pytest: `pytest --cov`
- Go: `go test -cover`
## Analyze Results
1. Parse coverage report
2. Identify:
- Files with <80% coverage
- Functions without tests
- Branches not covered
- Edge cases missing
## Generate Suggestions
For each gap, suggest a specific test:
Overall: 73% line coverage
Files needing attention:
Suggested tests:
PaymentService.refund()
it('should refund a valid transaction', async () => {
const result = await service.refund('tx123');
expect(result.success).toBe(true);
});
validation.isValidPhone()
## Generate Test File
If requested, generate a complete test file for uncovered functionality.
Problem: Tests break when you refactor.
Solution: Test behavior, not implementation:
// Bad: Tests implementation details
it('should store user in _users array', () => {
service.createUser(data);
expect(service._users.length).toBe(1);
});
// Good: Tests behavior
it('should allow retrieving created user', () => {
const created = service.createUser(data);
const found = service.getUser(created.id);
expect(found).toEqual(created);
});
Problem: Code has too many dependencies.
Solution: Use dependency injection and interfaces:
// Define interfaces for dependencies
interface Logger {
log(message: string): void;
}
interface Database {
save(data: any): Promise<void>;
}
// Inject dependencies
class UserService {
constructor(
private db: Database,
private logger: Logger
) {}
}
// Test with mocks
const mockDb = { save: jest.fn() };
const mockLogger = { log: jest.fn() };
const service = new UserService(mockDb, mockLogger);
Problem: Tests sometimes pass, sometimes fail.
Solution: Avoid timing dependencies:
// Bad: Depends on timing
it('should eventually complete', async () => {
service.start();
await sleep(1000);
expect(service.isComplete).toBe(true);
});
// Good: Uses proper async patterns
it('should complete', async () => {
const result = await service.start();
expect(result.isComplete).toBe(true);
});
/tdd to generate implementationsWhen adding features:
/tdd for quick implementation/coverage
Ensure new code is tested before committing.
Tests serve as documentation. Reviewers should:
TDD with Claude Code is faster than traditional development:
| Command | Purpose |
|---|---|
/tdd [file] | Generate implementation from tests |
/test [path] | Run tests |
/coverage | Analyze test coverage |
/tdd generates implementation (Green)Want to automate code quality beyond testing? Check out Automating Code Review for review automation skills.
Build evaluation frameworks for agent systems with metrics and benchmarks
Collaborative testing approaches using multiple sub-agents.
Identify and avoid ineffective testing practices in your codebase.
Model-invoked Playwright automation for testing and validating web applications.