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.
Test-Driven Development with Claude Code: Practical Guide
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.
Why TDD Works Better with AI
The Traditional TDD Problem
Classic TDD follows the Red-Green-Refactor cycle:
- Red: Write a failing test
- Green: Write minimal code to pass
- Refactor: Improve the code
The problem? Step 2 can take a long time. You know what you want, but implementing it requires thought, debugging, and iteration.
The AI-Assisted TDD Advantage
With Claude Code, the cycle becomes:
- Red: Write a failing test (you do this)
- Green: Claude generates implementation (instant)
- Refactor: Claude refines based on feedback (fast)
The test becomes your specification. Claude turns specification into implementation. Your job shifts from "write code" to "define behavior."
Setting Up TDD Workflow
Project Structure
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.
Create the TDD Skill
.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
TDD Session: [test_file]
Phase 1: Test Analysis
- Testing: UserService class
- Tests found: 5
- Functions required: createUser, getUser, updateUser, deleteUser
- Edge cases: empty name, duplicate email, not found
Phase 2: Initial Test Run
- Result: 5 failing tests ✗
- Ready to implement
Phase 3: Implementation
- Created: src/services/UserService.ts
- Functions implemented: 4
Phase 4: Verification
- Test run: 5 passing ✓
Phase 5: Refactor
- Extracted validation logic
- Added type annotations
- All tests still passing ✓
Implementation complete!
Create Supporting Skills
.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
TDD in Practice: Building a User Service
Let's walk through a complete TDD session. We'll build a UserService from scratch.
Step 1: Write the Tests First
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');
});
});
});
Step 2: Run TDD Command
/tdd tests/unit/UserService.test.ts
Step 3: Claude Generates Implementation
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);
}
}
Step 4: Verify All Tests Pass
/test tests/unit/UserService.test.ts
All 9 tests pass. The implementation matches the specification.
Advanced TDD Patterns
Pattern 1: Iterative Test Addition
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
Pattern 2: Interface-First Design
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();
});
});
Pattern 3: Behavior Tables
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.
Pattern 4: Mock-Driven Development
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.
Building a Test Coverage Skill
.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:
Coverage Analysis
Overall: 73% line coverage
Files needing attention:
- src/services/PaymentService.ts (45%)
- src/utils/validation.ts (62%)
Suggested tests:
-
PaymentService.refund()
- Currently untested
- Suggested test:
it('should refund a valid transaction', async () => { const result = await service.refund('tx123'); expect(result.success).toBe(true); }); -
validation.isValidPhone()
- Missing edge cases
- Suggested tests:
- Empty string input
- International formats
- Extensions
## Generate Test File
If requested, generate a complete test file for uncovered functionality.
Common TDD Challenges and Solutions
Challenge 1: Tests Are Too Implementation-Specific
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);
});
Challenge 2: Hard to Test in Isolation
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);
Challenge 3: Async Tests Are Flaky
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);
});
Integrating TDD into Daily Workflow
Morning Session
- Review yesterday's tests
- Write tests for today's feature
- Run
/tddto generate implementations - Refactor and polish
During Development
When adding features:
- Write test first
- Run
/tddfor quick implementation - Adjust tests if behavior should change
- Repeat
Before Committing
/coverage
Ensure new code is tested before committing.
Code Review
Tests serve as documentation. Reviewers should:
- Read tests first to understand expected behavior
- Verify implementation matches test expectations
- Suggest additional test cases
Summary
TDD with Claude Code is faster than traditional development:
- You write tests defining expected behavior
- Claude generates implementation from tests
- You refine tests, Claude refines implementation
- Result: Better tested, more reliable code in less time
Key Commands
| Command | Purpose |
|---|---|
/tdd [file] | Generate implementation from tests |
/test [path] | Run tests |
/coverage | Analyze test coverage |
TDD Cycle with Claude
- Write failing test (Red)
/tddgenerates implementation (Green)- Refine together (Refactor)
- Add more tests, repeat
Want to automate code quality beyond testing? Check out Automating Code Review for review automation skills.