test-driven-development
Test-Driven Development Skill
Use this skill PROACTIVELY - Apply TDD to all code changes by default. Tests come first, implementation follows.
Core Philosophy
TDD is about design, confidence, and rapid feedback. Write tests first to guide better interfaces and simpler implementations. The goal: "Clean code that works" — Ron Jeffries
The Red-Green-Refactor Cycle
This is your core workflow - use it for every code change:
Step 1: RED - Write a Failing Test
Write ONE test that describes the behavior you want:
describe('calculateDiscount', () => {
it('applies 10% discount when total exceeds $100', () => {
const total = calculateDiscount(150)
expect(total).toBe(135)
})
})
Run the test - It MUST fail. This proves you're testing something real.
Step 2: GREEN - Make It Pass
Write the minimum code to make the test pass:
function calculateDiscount(amount: number): number {
if (amount > 100) {
return amount * 0.9
}
return amount
}
Run the test - It should now pass. Don't add extra features or perfect the code yet.
Step 3: REFACTOR - Clean Up
Improve the code while keeping all tests green:
- Remove duplication
- Improve naming
- Simplify logic
- Extract functions
Run tests after each change - They must stay green.
Step 4: REPEAT
Pick the next test and start over. Small steps, frequent validation.
What to Test with TDD
Apply TDD proactively to:
✅ New features - Start with a test describing desired behavior
✅ Bug fixes - Write a failing test that reproduces the bug, then fix it
✅ Refactoring - Tests ensure behavior doesn't change
✅ API endpoints - Test request/response contracts
✅ Business logic - Test calculations, validations, transformations
✅ Any code change - Default to TDD unless truly impossible
Skip only for:
- Trivial config changes
- Pure exploratory spikes (throw away the code after)
- Generated code (test the generator or output, not both)
Test Types to Write
Unit Tests (Mandatory)
Test individual functions in isolation:
describe('validateEmail', () => {
it('returns true for valid email', () => {
expect(validateEmail('user@example.com')).toBe(true)
})
it('returns false for missing @', () => {
expect(validateEmail('userexample.com')).toBe(false)
})
it('handles null gracefully', () => {
expect(validateEmail(null)).toBe(false)
})
})
Integration Tests (For APIs & Database)
Test components working together:
describe('POST /api/users', () => {
it('creates user and returns 201', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com' })
expect(response.status).toBe(201)
expect(response.body.email).toBe('test@example.com')
})
it('returns 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid' })
expect(response.status).toBe(400)
})
})
Edge Cases to Test
Always test these scenarios:
- Null/Undefined - What if input is null?
- Empty - Empty array, empty string, zero
- Invalid Types - Wrong type passed in
- Boundaries - Min/max values, limits
- Errors - Network failures, database errors
- Special Characters - Unicode, SQL injection attempts
Mocking External Dependencies
Mock at system boundaries to keep tests fast and reliable:
Mock APIs
jest.mock('@/lib/api-client', () => ({
fetchUser: jest.fn(() => Promise.resolve({
id: 1,
email: 'test@example.com'
}))
}))
Mock Database
jest.mock('@/lib/database', () => ({
query: jest.fn(() => Promise.resolve([
{ id: 1, name: 'Test User' }
]))
}))
Don't over-mock: Only mock external dependencies (APIs, databases, file system). Let real code run when possible.
Common Mistakes to Avoid
❌ Skipping the RED step - Always see the test fail first
❌ Writing too much code - Only write enough to pass the current test
❌ Skipping refactoring - Clean up continuously, don't accumulate debt
❌ Testing implementation - Test behavior/outputs, not internal details
❌ Over-mocking - Mock only external dependencies, not everything
TDD Workflow Checklist
Before Starting
- Understand the behavior I'm implementing
- Test environment is set up and running
- Ready to see RED first
Each Cycle (Repeat)
- Write ONE test describing desired behavior
- Run test - verify it FAILS (RED)
- Write minimum code to make it pass
- Run test - verify it PASSES (GREEN)
- Refactor code while keeping tests green
- Run all tests - verify nothing broke
Before Committing
- All tests passing
- Code is clean and readable
- No unnecessary code added
Quick Reference
RED → GREEN → REFACTOR → REPEAT
RED: Write test. Watch it FAIL.
GREEN: Write minimum code to PASS.
REFACTOR: Clean up. Keep tests GREEN.
Remember: Tests first, always. No production code without a failing test driving it.