Test-Driven Development in Practice: Writing Tests That Matter
Test-Driven Development in Practice: Writing Tests That Matter
Test-Driven Development (TDD) is one of those practices that sounds simple in theory but can feel awkward in practice. Write a failing test, make it pass, refactor. Red, green, refactor. Easy, right?
After years of practicing TDD across different projects and teams, I’ve learned that the real challenge isn’t understanding the cycle - it’s knowing what tests to write, when to write them, and how to make TDD actually improve your codebase rather than slow you down.
The Reality of TDD
Let me be honest: TDD isn’t always the right tool for every situation. I’ve seen teams force TDD into every scenario and end up with brittle test suites that break with every small change. I’ve also seen teams skip TDD entirely and end up with codebases that are terrifying to modify.
The key is understanding when TDD provides value and how to apply it effectively.
When TDD Shines
Business Logic and Algorithms
TDD is exceptional for writing business logic and algorithms where the requirements are clear:
// Test first: What should the calculation do?
describe('calculateShippingCost', () => {
it('applies free shipping for orders over $100', () => {
const order = { subtotal: 150, items: [] };
expect(calculateShippingCost(order)).toBe(0);
});
it('charges $5.99 for orders under $50', () => {
const order = { subtotal: 35, items: [] };
expect(calculateShippingCost(order)).toBe(5.99);
});
it('charges $3.99 for orders between $50 and $100', () => {
const order = { subtotal: 75, items: [] };
expect(calculateShippingCost(order)).toBe(3.99);
});
});
// Implementation follows
function calculateShippingCost(order: Order): number {
if (order.subtotal >= 100) return 0;
if (order.subtotal >= 50) return 3.99;
return 5.99;
}
The tests document the business rules clearly. Anyone can read them and understand the shipping policy immediately.
Bug Fixes
When you find a bug, write a failing test first:
// Bug report: "Users with expired tokens can still access resources"
it('rejects requests with expired tokens', async () => {
const expiredToken = generateToken({ expiresAt: Date.now() - 1000 });
await expect(
authenticateRequest(expiredToken)
).rejects.toThrow('Token expired');
});
This test will fail (reproducing the bug), then you fix the code to make it pass. Now you have regression protection built-in.
Refactoring Legacy Code
TDD is powerful when refactoring existing code. Write tests for the current behavior first, then refactor with confidence:
// Step 1: Add tests for existing behavior
describe('legacy user registration', () => {
it('creates user with hashed password', async () => {
const result = await registerUser('test@example.com', 'password123');
expect(result.user.password).not.toBe('password123');
expect(result.user.email).toBe('test@example.com');
});
it('sends welcome email', async () => {
await registerUser('test@example.com', 'password123');
expect(emailService.send).toHaveBeenCalledWith({
to: 'test@example.com',
template: 'welcome'
});
});
});
// Step 2: Refactor safely - tests ensure behavior is preserved
The TDD Cycle in Practice
Red: Write a Failing Test
Start with the simplest possible test that describes what you want:
describe('UserValidator', () => {
it('validates email format', () => {
const validator = new UserValidator();
expect(validator.isValidEmail('test@example.com')).toBe(true);
expect(validator.isValidEmail('invalid-email')).toBe(false);
});
});
Run it. Watch it fail. This confirms your test is actually testing something.
Green: Make It Pass
Write the minimum code to make the test pass:
class UserValidator {
isValidEmail(email: string): boolean {
return email.includes('@') && email.includes('.');
}
}
Yes, this is a naive implementation. That’s fine for now. The test passes.
Refactor: Improve the Code
Now that you have a safety net, improve the implementation:
class UserValidator {
isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
Run the tests again. Still green? You’ve successfully refactored.
Writing Tests That Matter
Focus on Behavior, Not Implementation
Bad test - coupled to implementation:
it('calls the database save method', async () => {
const mockDb = jest.fn();
const service = new UserService(mockDb);
await service.createUser({ name: 'Alice' });
expect(mockDb).toHaveBeenCalled();
});
This test breaks if you change how you save data, even if the behavior is correct.
Good test - focused on behavior:
it('persists user data across sessions', async () => {
const service = new UserService(database);
const userId = await service.createUser({ name: 'Alice' });
// Simulate new session
const newService = new UserService(database);
const user = await newService.getUser(userId);
expect(user.name).toBe('Alice');
});
This test verifies what actually matters: the data persists.
Test One Thing at a Time
Each test should verify a single behavior:
// Bad: Testing multiple things
it('handles user registration', async () => {
const user = await registerUser('test@example.com', 'pass123');
expect(user.email).toBe('test@example.com');
expect(user.createdAt).toBeDefined();
expect(emailService.send).toHaveBeenCalled();
expect(analytics.track).toHaveBeenCalledWith('user_registered');
});
// Good: Separate tests for each concern
describe('user registration', () => {
it('creates user with correct email', async () => {
const user = await registerUser('test@example.com', 'pass123');
expect(user.email).toBe('test@example.com');
});
it('sends welcome email', async () => {
await registerUser('test@example.com', 'pass123');
expect(emailService.send).toHaveBeenCalledWith(
expect.objectContaining({ template: 'welcome' })
);
});
it('tracks registration event', async () => {
await registerUser('test@example.com', 'pass123');
expect(analytics.track).toHaveBeenCalledWith('user_registered');
});
});
When a test fails, you know exactly what broke.
Use Descriptive Test Names
Your test names should read like documentation:
describe('ShoppingCart', () => {
describe('when adding items', () => {
it('increases the total price', () => {});
it('updates the item count', () => {});
it('preserves existing items', () => {});
});
describe('when removing items', () => {
it('decreases the total price', () => {});
it('removes item from cart', () => {});
it('handles removing non-existent items gracefully', () => {});
});
describe('when applying discount codes', () => {
it('reduces total by discount percentage', () => {});
it('rejects invalid codes', () => {});
it('prevents applying same code twice', () => {});
});
});
Anyone reading your test suite should understand your system’s behavior.
Common TDD Pitfalls
Testing Too Much Implementation Detail
Don’t test private methods. Test public interfaces:
// Bad
class Calculator {
private sanitizeInput(value: string): number {
return parseFloat(value.trim());
}
}
it('sanitizes input correctly', () => {
// Don't test this directly
});
// Good
it('handles inputs with whitespace', () => {
const calc = new Calculator();
expect(calc.calculate(' 5 + 3 ')).toBe(8);
});
If your public interface works, implementation details can change freely.
Writing Tests After the Code
This defeats the purpose. When you write tests after, they often become “confirmation tests” that just verify what you already wrote:
// You wrote this code first
function processPayment(amount: number): boolean {
return amount > 0 && amount < 10000;
}
// Then wrote this test
it('processes valid payments', () => {
expect(processPayment(50)).toBe(true); // Just confirms what you wrote
});
With TDD, the test would have revealed edge cases you hadn’t considered:
// Writing test first makes you think
describe('processPayment', () => {
it('accepts valid amounts', () => {});
it('rejects zero amounts', () => {});
it('rejects negative amounts', () => {});
it('rejects amounts exceeding limit', () => {});
it('handles decimal amounts correctly', () => {});
it('handles currency precision', () => {});
});
Over-Mocking
Excessive mocking makes tests brittle and meaningless:
// Too much mocking
it('creates order', async () => {
const mockDb = { save: jest.fn().mockResolvedValue({ id: 1 }) };
const mockEmail = { send: jest.fn().mockResolvedValue(true) };
const mockPayment = { charge: jest.fn().mockResolvedValue({ success: true }) };
const mockInventory = { reserve: jest.fn().mockResolvedValue(true) };
const service = new OrderService(mockDb, mockEmail, mockPayment, mockInventory);
await service.createOrder({ items: [] });
expect(mockDb.save).toHaveBeenCalled();
// This test tells you nothing about whether orders actually work
});
Use real implementations when possible, mock only external dependencies:
// Better approach
it('creates order end-to-end', async () => {
// Use real database (in-memory for tests)
// Use real business logic
// Mock only external APIs (payment gateway, email service)
const order = await orderService.createOrder({
items: [{ id: 'item-1', quantity: 2 }]
});
expect(order.status).toBe('confirmed');
expect(order.total).toBe(39.98);
// Verify email was sent
expect(emailService.send).toHaveBeenCalledWith({
to: order.customer.email,
template: 'order-confirmation'
});
});
TDD for Different Scenarios
API Endpoints
describe('POST /api/users', () => {
it('creates new user with valid data', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test User' })
.expect(201);
expect(response.body.user.email).toBe('test@example.com');
});
it('returns 400 for invalid email', async () => {
await request(app)
.post('/api/users')
.send({ email: 'invalid', name: 'Test User' })
.expect(400);
});
it('returns 409 for duplicate email', async () => {
await createUser({ email: 'existing@example.com' });
await request(app)
.post('/api/users')
.send({ email: 'existing@example.com', name: 'Test User' })
.expect(409);
});
});
Data Transformations
describe('formatUserForDisplay', () => {
it('masks sensitive information', () => {
const user = {
email: 'user@example.com',
ssn: '123-45-6789',
name: 'John Doe'
};
const formatted = formatUserForDisplay(user);
expect(formatted.ssn).toBe('***-**-6789');
expect(formatted.name).toBe('John Doe');
});
it('handles missing optional fields', () => {
const user = {
email: 'user@example.com',
name: 'John Doe'
};
const formatted = formatUserForDisplay(user);
expect(formatted.ssn).toBeUndefined();
});
});
State Management
describe('CartStore', () => {
it('adds item to empty cart', () => {
const store = new CartStore();
store.addItem({ id: 'item-1', price: 10 });
expect(store.items).toHaveLength(1);
expect(store.total).toBe(10);
});
it('increases quantity for duplicate items', () => {
const store = new CartStore();
store.addItem({ id: 'item-1', price: 10 });
store.addItem({ id: 'item-1', price: 10 });
expect(store.items).toHaveLength(1);
expect(store.items[0].quantity).toBe(2);
expect(store.total).toBe(20);
});
});
Advanced TDD Techniques
Parameterized Tests
Test multiple scenarios efficiently:
describe('validatePassword', () => {
test.each([
['abc', false, 'too short'],
['abcdefgh', false, 'no numbers'],
['12345678', false, 'no letters'],
['Abc123!@', true, 'valid password'],
['password123', true, 'valid password'],
])('validates "%s" as %s because %s', (password, expected, reason) => {
expect(validatePassword(password)).toBe(expected);
});
});
Test Fixtures and Factories
Create reusable test data:
// test/factories/user.factory.ts
export function createTestUser(overrides?: Partial<User>): User {
return {
id: randomUUID(),
email: `test-${Date.now()}@example.com`,
name: 'Test User',
createdAt: new Date(),
...overrides
};
}
// Usage in tests
it('handles premium users differently', () => {
const premiumUser = createTestUser({
subscription: 'premium',
features: ['advanced-analytics']
});
expect(canAccessFeature(premiumUser, 'advanced-analytics')).toBe(true);
});
Testing Async Code
describe('async operations', () => {
it('waits for promises to resolve', async () => {
const result = await fetchUserData('user-123');
expect(result.name).toBeDefined();
});
it('handles timeouts', async () => {
await expect(
fetchUserData('user-123', { timeout: 10 })
).rejects.toThrow('Timeout');
});
it('retries failed requests', async () => {
const fetch = jest.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ data: 'success' });
const result = await fetchWithRetry(fetch, 3);
expect(fetch).toHaveBeenCalledTimes(3);
expect(result.data).toBe('success');
});
});
Building a TDD Mindset
Start Small
Don’t try to TDD everything at once. Start with:
- New features with clear requirements
- Bug fixes (write failing test first)
- Business logic and calculations
- Utilities and helper functions
Embrace the Learning Curve
TDD feels slow at first. That’s normal. You’re learning to think differently about code design.
Over time, you’ll get faster because:
- You write less buggy code initially
- You catch issues immediately
- Refactoring becomes safer and faster
- You spend less time debugging
Use TDD as a Design Tool
Tests often reveal design problems:
// If this test is hard to write...
it('processes complex user action', () => {
const service = new UserService(
mockDb, mockEmail, mockAuth, mockLogger,
mockAnalytics, mockCache, mockQueue, mockStorage
);
// ...8 dependencies? This class is doing too much
});
// Your code probably needs refactoring
When tests are painful, listen to them. They’re telling you something about your design.
Measuring Success
Good TDD practice shows up as:
- Code coverage: 80%+ coverage on business logic
- Fast tests: Unit tests run in seconds
- Confidence: You refactor without fear
- Documentation: Tests explain how code works
- Bug prevention: Fewer production issues
Tools and Setup
Essential Testing Tools
// package.json
{
"devDependencies": {
"jest": "^29.0.0",
"@testing-library/react": "^14.0.0",
"supertest": "^6.3.0",
"ts-jest": "^29.0.0"
},
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Jest Configuration
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
testMatch: ['**/__tests__/**/*.test.ts']
};
Watch Mode Workflow
# Run tests in watch mode
npm run test:watch
# Make a change to code or tests
# Tests automatically re-run
# Get immediate feedback
Real-World Example: Building a Feature with TDD
Let’s build a simple rate limiter using TDD:
// Step 1: Write first test
describe('RateLimiter', () => {
it('allows requests within limit', () => {
const limiter = new RateLimiter({ maxRequests: 3, windowMs: 1000 });
expect(limiter.tryRequest('user-1')).toBe(true);
expect(limiter.tryRequest('user-1')).toBe(true);
expect(limiter.tryRequest('user-1')).toBe(true);
});
});
// Step 2: Make it pass (minimal implementation)
class RateLimiter {
constructor(config: { maxRequests: number; windowMs: number }) {}
tryRequest(userId: string): boolean {
return true; // Simplest code to pass
}
}
// Step 3: Add next test
it('blocks requests exceeding limit', () => {
const limiter = new RateLimiter({ maxRequests: 3, windowMs: 1000 });
limiter.tryRequest('user-1');
limiter.tryRequest('user-1');
limiter.tryRequest('user-1');
expect(limiter.tryRequest('user-1')).toBe(false);
});
// Step 4: Implement to pass both tests
class RateLimiter {
private requests = new Map<string, number[]>();
constructor(
private config: { maxRequests: number; windowMs: number }
) {}
tryRequest(userId: string): boolean {
const now = Date.now();
const userRequests = this.requests.get(userId) || [];
// Filter recent requests
const recentRequests = userRequests.filter(
time => now - time < this.config.windowMs
);
if (recentRequests.length >= this.config.maxRequests) {
return false;
}
recentRequests.push(now);
this.requests.set(userId, recentRequests);
return true;
}
}
// Step 5: Add edge cases
it('resets window after time expires', async () => {
const limiter = new RateLimiter({ maxRequests: 2, windowMs: 100 });
expect(limiter.tryRequest('user-1')).toBe(true);
expect(limiter.tryRequest('user-1')).toBe(true);
expect(limiter.tryRequest('user-1')).toBe(false);
await sleep(150);
expect(limiter.tryRequest('user-1')).toBe(true);
});
it('tracks different users independently', () => {
const limiter = new RateLimiter({ maxRequests: 2, windowMs: 1000 });
expect(limiter.tryRequest('user-1')).toBe(true);
expect(limiter.tryRequest('user-1')).toBe(true);
expect(limiter.tryRequest('user-2')).toBe(true);
expect(limiter.tryRequest('user-2')).toBe(true);
});
Each test drove a piece of functionality. The final implementation is well-tested and handles edge cases we might have forgotten without TDD.
Practical Advice
Don’t Be Dogmatic
TDD is a tool, not a religion. Sometimes you need to:
- Spike a solution to understand the problem
- Prototype UI without tests initially
- Work with external APIs where testing is difficult
That’s fine. Use TDD where it helps.
Test the Right Things
Don’t test:
- Third-party libraries (they have their own tests)
- Simple getters/setters with no logic
- Framework code
Do test:
- Your business logic
- Edge cases and error handling
- Integration points
- Complex algorithms
Keep Tests Fast
Slow tests kill TDD. Keep your test suite under 10 seconds:
- Use in-memory databases for tests
- Mock external APIs
- Parallelize test execution
- Split slow integration tests from fast unit tests
The Long-Term Benefits
After practicing TDD consistently for several months, you’ll notice:
- Design Improvements: Your code becomes more modular and testable
- Faster Development: Less debugging, more confidence
- Better APIs: Testing forces you to think about usability
- Living Documentation: Tests show how code should be used
- Fearless Refactoring: Change code without breaking things
Getting Started Tomorrow
Want to start practicing TDD? Here’s your action plan:
- Pick a new feature or bug fix
- Write one failing test
- Make it pass with minimal code
- Refactor if needed
- Repeat
Don’t try to TDD your entire project overnight. Start small, build the habit, and gradually expand.
Resources
- Test Driven Development: By Example by Kent Beck
- Growing Object-Oriented Software, Guided by Tests
- Jest Documentation
- Testing Library
Part of the Developer Skills series. Write code you can trust.
TDD isn’t about writing perfect code on the first try - it’s about building confidence through rapid feedback cycles. Start small, stay consistent, and watch your code quality improve dramatically.