Test-Driven Development (TDD)
Test-Driven Development is a software development methodology where you write tests before writing the actual code. This approach ensures your code is testable, well-designed, and meets requirements from the start.
Why TDD?
- Better Design - Writing tests first forces you to think about API design
- Higher Confidence - Tests prove your code works as expected
- Less Debugging - Catch bugs early, before they compound
- Living Documentation - Tests show how code should be used
- Easier Refactoring - Change code confidently with test safety net
The TDD Cycle: Red-Green-Refactor
1
Red
Write a failing test
→
2
Green
Make it pass
→
3
Refactor
Improve the code
↻
The TDD Cycle (Red-Green-Refactor):
1. RED - Write a failing test
- Write a test for the next bit of functionality
- Test should fail (no implementation yet)
2. GREEN - Make the test pass
- Write minimal code to make test pass
- Don't worry about perfection, just make it work
3. REFACTOR - Improve the code
- Clean up code while keeping tests green
- Remove duplication, improve design
Repeat the cycle for each new feature! TDD Example: Step by Step
Step 1: Red - Write a Failing Test
// Step 1: Write a failing test (RED)
describe('Calculator', () => {
test('add should sum two numbers', () => {
const calc = new Calculator();
expect(calc.add(2, 3)).toBe(5);
});
});
// This test fails because Calculator doesn't exist yet Step 2: Green - Make It Pass
// Step 2: Write minimal code to pass (GREEN)
class Calculator {
add(a, b) {
return a + b;
}
}
// Test now passes! Step 3: Refactor - Improve the Code
// Step 3: Refactor if needed (REFACTOR)
class Calculator {
add(...numbers) {
return numbers.reduce((sum, num) => sum + num, 0);
}
}
// Tests still pass, but code is more flexible! Complete TDD Workflow Example
Building a password validator using TDD:
// Complete TDD example: Building a Password Validator
// Test 1: Minimum length
test('password must be at least 8 characters', () => {
const validator = new PasswordValidator();
expect(validator.isValid('short')).toBe(false);
expect(validator.isValid('longenough')).toBe(true);
});
// Implementation
class PasswordValidator {
isValid(password) {
return password.length >= 8;
}
}
// Test 2: Must contain uppercase
test('password must contain uppercase letter', () => {
const validator = new PasswordValidator();
expect(validator.isValid('lowercase')).toBe(false);
expect(validator.isValid('Uppercase')).toBe(true);
});
// Implementation (expand)
class PasswordValidator {
isValid(password) {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
return true;
}
}
// Test 3: Must contain number
test('password must contain a number', () => {
const validator = new PasswordValidator();
expect(validator.isValid('NoNumbers')).toBe(false);
expect(validator.isValid('HasNumber1')).toBe(true);
});
// Implementation (expand)
class PasswordValidator {
isValid(password) {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
if (!/\d/.test(password)) return false;
return true;
}
}
// Refactor: Make it cleaner
class PasswordValidator {
constructor() {
this.rules = [
{ test: (pwd) => pwd.length >= 8, message: 'Too short' },
{ test: (pwd) => /[A-Z]/.test(pwd), message: 'No uppercase' },
{ test: (pwd) => /\d/.test(pwd), message: 'No number' }
];
}
isValid(password) {
return this.rules.every(rule => rule.test(password));
}
getErrors(password) {
return this.rules
.filter(rule => !rule.test(password))
.map(rule => rule.message);
}
} Writing Good Tests
// Best practices for writing tests
// 1. Descriptive test names
test('throws error when dividing by zero', () => {
// Clear what's being tested
});
// 2. Arrange-Act-Assert pattern
test('calculates discount correctly', () => {
// Arrange - set up test data
const calculator = new DiscountCalculator();
const price = 100;
// Act - perform action
const result = calculator.applyDiscount(price, 0.2);
// Assert - verify result
expect(result).toBe(80);
});
// 3. Test one thing at a time
test('validates email format', () => {
const validator = new EmailValidator();
expect(validator.isValid('test@example.com')).toBe(true);
});
test('rejects invalid email', () => {
const validator = new EmailValidator();
expect(validator.isValid('notanemail')).toBe(false);
});
// 4. Use meaningful assertions
expect(user.age).toBe(25); // Exact value
expect(users).toHaveLength(3); // Array length
expect(error).toContain('Invalid'); // String contains
expect(callback).toHaveBeenCalledWith(42); // Function called
expect(promise).resolves.toBe('success'); // Async result Benefits of TDD
- Design First - Think about how code will be used before writing it
- Incremental Development - Build features in small, testable chunks
- Regression Prevention - Tests catch bugs when changing code
- Documentation - Tests serve as examples of how to use the code
- Confidence - Refactor fearlessly with comprehensive test coverage
- Fewer Bugs - Issues caught early in development
Common Challenges
- Initial Slowdown - TDD takes time to learn and feels slower at first
- Over-Testing - Testing implementation details instead of behavior
- Test Maintenance - Brittle tests break when refactoring
- Legacy Code - Hard to apply TDD to existing untested code
- Team Buy-in - Requires discipline and team commitment
TDD Best Practices
- Start Simple - Begin with the simplest test case
- One Test at a Time - Focus on making one test pass before writing the next
- Keep Tests Fast - Fast tests encourage running them frequently
- Test Behavior, Not Implementation - Tests should survive refactoring
- Use Meaningful Names - Test names should describe what they verify
- Follow AAA Pattern - Arrange, Act, Assert structure
- Don't Skip Refactoring - Green doesn't mean done; refactor before moving on
When to Use TDD
- New Features - Perfect for greenfield development
- Bug Fixes - Write test that reproduces bug, then fix it
- Complex Logic - Business rules, algorithms, validation
- APIs - Design public interfaces through tests
- Refactoring - Write tests first if they don't exist
When TDD May Not Fit:
- Prototyping and exploratory coding
- Simple CRUD operations with frameworks
- UI layout and styling (use visual testing instead)
- Throw-away code or spikes
Testing Frameworks
- Jest - Popular for JavaScript, built-in mocking and assertions
- Mocha - Flexible test framework, pair with Chai for assertions
- Vitest - Fast Vite-native testing, Jest-compatible API
- Jasmine - Behavior-driven development framework
- QUnit - jQuery's testing framework, simple and lightweight
- Node Test Runner - Built into Node.js (v18+)
Resources
- Books - "Test Driven Development: By Example" by Kent Beck
- Courses - "Test-Driven Development" on Pluralsight, Udemy
- Practice - Coding Katas for TDD practice
- Tools - Jest, Vitest, Testing Library, Cypress