Test-Driven Development: The Complete Guide to TDD
Test-driven development (TDD) is a software development practice where you write tests before writing code. It sounds backwards at first — how can you test something that doesn't exist yet? But once you experience the feedback loop, it's hard to go back.
TDD changes how you think about code. Instead of asking "how do I implement this?", you ask "how should this behave?" That shift leads to simpler designs, better interfaces, and code that's actually testable.
The Red-Green-Refactor Cycle
TDD operates in three phases, repeated continuously:
Red — Write a failing test. The test describes behavior you want but haven't implemented yet. Run it. It should fail. If it passes immediately, the test is wrong or the behavior already exists.
Green — Write the minimum code to make the test pass. Don't over-engineer. Don't add features. Just make it green.
Refactor — Clean up the code while keeping tests green. Extract duplicates, rename things, improve structure. The tests protect you from breaking anything.
// Red: write a failing test
test('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
// ReferenceError: add is not defined ✗
// Green: write minimum code
function add(a, b) {
return a + b;
}
// ✓
// Refactor: nothing to clean up yet — move onThat example is trivial. Real TDD gets interesting when you tackle complex behavior incrementally.
A Real TDD Example
Let's build a simple shopping cart with TDD.
Start with the simplest test
describe('ShoppingCart', () => {
test('starts empty', () => {
const cart = new ShoppingCart();
expect(cart.itemCount()).toBe(0);
});
});Make it pass:
class ShoppingCart {
itemCount() {
return 0;
}
}Add the next behavior
test('adding an item increases count', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', name: 'Widget', price: 9.99 });
expect(cart.itemCount()).toBe(1);
});Make it pass:
class ShoppingCart {
constructor() {
this.items = [];
}
add(item) {
this.items.push(item);
}
itemCount() {
return this.items.length;
}
}Continue building behavior
test('calculates total price', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', price: 10.00 });
cart.add({ id: '2', price: 5.50 });
expect(cart.total()).toBe(15.50);
});
test('applies discount code', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', price: 100.00 });
cart.applyDiscount('SAVE10');
expect(cart.total()).toBe(90.00);
});
test('rejects invalid discount code', () => {
const cart = new ShoppingCart();
expect(() => cart.applyDiscount('INVALID')).toThrow('Invalid discount code');
});Each test drives the implementation forward. You discover edge cases as you write tests, not after shipping to production.
Why TDD Produces Better Code
It forces testable design
Code that's hard to test is usually hard to use. TDD surfaces design problems early. If writing a test is painful — lots of setup, many dependencies to mock, complex state — that's a signal your design needs work.
When you write tests first, you naturally design for testability: small functions, clear interfaces, dependency injection instead of hard-coded dependencies.
It creates a specification
Your tests document exactly what the code does. Future developers (including future you) can read the tests to understand intended behavior without reverse-engineering the implementation.
It gives you confidence to refactor
Code without tests is scary to change. Code with comprehensive tests can be refactored aggressively — the test suite tells you immediately if you broke anything.
It prevents over-engineering
TDD keeps you honest. You can only add code that makes a failing test pass. No speculative features, no "I might need this later" abstractions.
Common TDD Mistakes
Writing too much test at once
Don't write 10 tests, then implement. Write one test, make it green, refactor, repeat. If you write many tests first, you have a pile of failing tests and no feedback on whether your design is working.
Testing implementation details
Test behavior, not implementation:
// Bad: tests internal state
test('stores items in array', () => {
const cart = new ShoppingCart();
cart.add(item);
expect(cart.items).toHaveLength(1); // tests private state
});
// Good: tests behavior
test('reports item count after adding', () => {
const cart = new ShoppingCart();
cart.add(item);
expect(cart.itemCount()).toBe(1); // tests public interface
});If you refactor internal storage from an array to a Map, the bad test breaks. The good test stays green.
Skipping the refactor step
Many developers do red-green and skip refactor. This leads to test-covered but messy code. The refactor step is not optional — it's where clean code emerges.
Tests that are too complex
If a test needs a 50-line setup, something is wrong. Either the code has too many dependencies, or the test is trying to test too much at once. Break it down.
Test Structure: Arrange-Act-Assert
Structure every test with three parts:
test('removes item from cart', () => {
// Arrange — set up the scenario
const cart = new ShoppingCart();
const item = { id: '1', name: 'Widget', price: 9.99 };
cart.add(item);
// Act — perform the operation
cart.remove('1');
// Assert — verify the outcome
expect(cart.itemCount()).toBe(0);
});Keep each section clearly separated. One assertion per test is ideal, though related assertions (like checking multiple properties of a returned object) can live together.
When TDD Shines
Business logic — calculators, state machines, data transformations, validation rules. TDD is perfect here. Pure functions are easy to test.
Bug fixes — write a failing test that reproduces the bug, then fix it. The test prevents regression.
Refactoring — write tests for existing behavior, then refactor. The tests tell you if you break anything.
APIs — define the interface through tests before implementing it.
When TDD Is Harder
Exploratory code — when you don't know what you're building yet, write exploratory code first, then extract and test what you've learned.
UI components — visual testing is awkward with unit tests. Test the logic, use snapshot tests for structure, and use end-to-end tests for user flows.
Infrastructure — testing that a database migration runs correctly or a server starts up requires integration tests, not pure TDD.
Third-party integrations — you can TDD the code that calls an API, mocking the HTTP layer. Don't TDD the third-party service itself.
Getting Started with TDD
If you've never practiced TDD, start small:
- Pick one simple piece of business logic to implement
- Write one test before writing any code
- Watch it fail
- Write the minimum code to make it pass
- Refactor
- Repeat
The first few times feel slow. After a week, it becomes natural. After a month, writing code without tests first feels wrong.
TDD is a skill that compounds. The longer you practice it, the better your intuition becomes about what to test and how to structure code for testability.