TDD Workflow
Test-Driven Development (TDD) is a development practice where you write tests before writing the actual code. It might seem backwards at first, but TDD leads to better-designed, more reliable software.
The Red-Green-Refactor Cycle
TDD follows a simple three-step cycle:
1. Red: Write a Failing Test
Write a test for the behavior you want. Run it - it should fail because the code doesn't exist yet.
// 1. RED - Write the test first
describe('add', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
});
// Running this fails: "add is not defined"
2. Green: Make It Pass
Write the minimum code needed to make the test pass.
// 2. GREEN - Write just enough code
function add(a, b) {
return a + b;
}
// Test passes!
3. Refactor: Improve the Code
Now that you have a passing test, you can safely refactor. The test ensures you don't break anything.
// 3. REFACTOR - Improve if needed
const add = (a, b) => a + b;
// Test still passes - refactoring was safe
Then repeat with the next requirement.
TDD in Practice
Let's build a shopping cart using TDD:
Step 1: Empty Cart
// RED: Write the test
describe('ShoppingCart', () => {
it('starts empty', () => {
const cart = new ShoppingCart();
expect(cart.items).toEqual([]);
expect(cart.total).toBe(0);
});
});
// GREEN: Make it pass
class ShoppingCart {
constructor() {
this.items = [];
this.total = 0;
}
}
Step 2: Adding Items
// RED: New test
it('can add an item', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Apple', price: 1.00 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].name).toBe('Apple');
});
// GREEN: Implement addItem
class ShoppingCart {
constructor() {
this.items = [];
this.total = 0;
}
addItem(item) {
this.items.push(item);
}
}
Step 3: Calculating Total
// RED: Test total calculation
it('calculates total correctly', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Apple', price: 1.00 });
cart.addItem({ name: 'Banana', price: 0.50 });
expect(cart.total).toBe(1.50);
});
// GREEN: Update total in addItem
addItem(item) {
this.items.push(item);
this.total += item.price;
}
Step 4: Removing Items
// RED: Test removal
it('can remove an item', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Apple', price: 1.00, id: 1 });
cart.addItem({ name: 'Banana', price: 0.50, id: 2 });
cart.removeItem(1);
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(0.50);
});
// GREEN: Implement removeItem
removeItem(id) {
const index = this.items.findIndex(item => item.id === id);
if (index !== -1) {
this.total -= this.items[index].price;
this.items.splice(index, 1);
}
}
Benefits of TDD
1. Better Design
TDD forces you to think about the interface before implementation:
// Without TDD, you might write:
function processData(data, options, callback, format, validate) {
// Complex implementation
}
// With TDD, you design the interface first:
it('should process data with sensible defaults', () => {
const result = processData({ items: [1, 2, 3] });
expect(result.processed).toBe(true);
});
// This leads to cleaner APIs
2. Built-in Documentation
Tests describe exactly how the code should behave:
describe('PasswordValidator', () => {
it('requires minimum 8 characters', () => {
expect(validate('short')).toBe(false);
expect(validate('longenough')).toBe(true);
});
it('requires at least one number', () => {
expect(validate('nonumbers')).toBe(false);
expect(validate('has1number')).toBe(true);
});
});
3. Confidence to Refactor
With comprehensive tests, you can refactor fearlessly:
// Original implementation
function isPalindrome(str) {
const cleaned = str.toLowerCase().replace(/[^a-z]/g, '');
return cleaned === cleaned.split('').reverse().join('');
}
// Tests still pass after refactoring
function isPalindrome(str) {
const cleaned = str.toLowerCase().replace(/[^a-z]/g, '');
let left = 0;
let right = cleaned.length - 1;
while (left < right) {
if (cleaned[left++] !== cleaned[right--]) return false;
}
return true;
}
4. Fewer Bugs
By defining expected behavior upfront, you catch issues immediately.
Try It: TDD Exercise
Build a Counter class using TDD:
When to Use TDD
TDD Works Well For:
- Well-defined requirements - You know what the code should do
- Complex logic - Algorithms, business rules, calculations
- Bug fixes - Write a failing test that reproduces the bug
- API design - Design the interface before implementation
- Learning new concepts - Forces you to understand requirements
TDD May Not Be Best For:
- Exploratory coding - When you're not sure what you're building
- Rapidly changing requirements - Tests become outdated quickly
- Simple glue code - Just connecting pieces together
- UI prototyping - Visual design needs flexibility
TDD Tips
Start Simple
Begin with the simplest possible test:
// Too complex for first test
it('validates email with all rules', () => {
expect(validate('user@domain.com')).toEqual({
valid: true,
normalized: 'user@domain.com',
domain: 'domain.com'
});
});
// Better: Start simple
it('accepts valid email', () => {
expect(isValidEmail('user@domain.com')).toBe(true);
});
Test Behavior, Not Implementation
// Bad: Testing implementation
it('uses regex to validate', () => {
expect(validator.regex).toBeDefined();
});
// Good: Testing behavior
it('rejects emails without @', () => {
expect(validate('invalid')).toBe(false);
});
Use Descriptive Test Names
// Bad
it('works', () => { /* ... */ });
// Good
it('returns empty array when input is empty', () => { /* ... */ });
One Assertion per Behavior
// Less clear
it('validates user', () => {
expect(validate({ name: '' })).toContain('name required');
expect(validate({ name: 'a' })).toContain('name too short');
expect(validate({ name: 'valid' })).toEqual([]);
});
// Clearer
it('requires name', () => {
expect(validate({ name: '' })).toContain('name required');
});
it('requires name length >= 2', () => {
expect(validate({ name: 'a' })).toContain('name too short');
});
The TDD Cycle in Real Projects
A typical TDD session:
- Pick a small feature - "Users can add items to cart"
- Write a failing test - Test the happy path first
- Make it pass - Simplest possible implementation
- Refactor - Clean up while tests pass
- Add edge cases - Empty cart, invalid items, etc.
- Repeat - Next feature
Write Test → Run (Red) → Write Code → Run (Green) → Refactor → Run (Green) → Next Test
Key Takeaways
- TDD means writing tests before code
- Follow Red-Green-Refactor: failing test → pass → improve
- Start with the simplest test case
- Each cycle adds one small piece of functionality
- TDD leads to better design and fewer bugs
- Tests serve as documentation
- Not everything needs TDD - use judgment
Next, we'll cover testing best practices to write maintainable, effective tests!

