Understand the TDD mindset: why writing tests before implementation produces better design, fewer bugs, and more maintainable code than testing after the fact.
Test-Driven Development is one of the most debated practices in software engineering. Developers who have never tried it often dismiss it as inefficient — why write a test for code you haven't written yet? Developers who practice it consistently report that it is one of the most significant improvements to their productivity and code quality they have ever made.
The key insight is that TDD is not primarily a testing technique. It is a design technique that uses tests as a tool for thinking through interfaces, identifying edge cases, and producing the minimal implementation needed to satisfy a specification. This lesson explains the philosophy, the three laws, and why the discipline pays compounding dividends over a project's lifetime.
Traditional development follows: think → code → test → fix → repeat. TDD follows: think → specify (test) → implement → refactor → repeat. The order changes everything.
Writing the test first forces you to answer:
These are the questions you must answer before implementing anyway — but without TDD, you often answer them implicitly and incorrectly while writing the code, then discover the mistakes in production.
1// TRADITIONAL: implement first, discover design problems in production
2function processOrder(order) {
3 // 200 lines of implementation
4 // Two months later: discovered that order can be null, items can be empty,
5 // currency codes are not validated, and the return type was assumed wrong
6}
7
8// TDD: design problems discovered before a single line of implementation
9describe('processOrder', () => {
10 it('throws when order is null', () => {
11 expect(() => processOrder(null)).toThrow('Order is required');
12 // Already designed: order can be null, we need to handle it
13 });
14
15 it('returns total of zero for empty items array', () => {
16 const result = processOrder({ id: '1', items: [], currency: 'USD' });
17 expect(result.total).toBe(0);
18 // Already designed: empty items is valid, returns zero total
19 });
20
21 it('throws for unsupported currency codes', () => {
22 expect(() => processOrder({ id: '1', items: [], currency: 'XYZ' }))
23 .toThrow('Unsupported currency: XYZ');
24 // Already designed: currency validation required
25 });
26});
27// Only NOW do we write the implementation — with all edge cases already specifiedThe tests are not overhead — they are the specification, written in executable code.
Kent Beck formulated three laws that define strict TDD:
Law 1: You may not write production code unless you have a failing test that requires it.
Law 2: You may not write more of a unit test than is sufficient to fail — and not compiling is failing.
Law 3: You may not write more production code than is sufficient to make the currently failing test pass.
These laws create a tight feedback loop. You oscillate between test and production code in cycles of under a minute.
1// LAW 1 VIOLATION: writing production code without a test
2function formatCurrency(amount: number, currency: string): string {
3 // This code has no test requiring it — TDD violation
4 return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
5}
6
7// LAW 1 CORRECT: test first
8// Step 1: Write failing test
9test('formats USD amount with $ symbol and 2 decimal places', () => {
10 expect(formatCurrency(49.99, 'USD')).toBe('$49.99');
11}); // FAILS — formatCurrency does not exist yet
12
13// Step 2: Write MINIMUM code to pass
14function formatCurrency(amount: number, currency: string): string {
15 return '$49.99'; // LAW 3 VIOLATION: hardcoded — but wait, this passes!
16}
17// This sounds absurd, but it forces you to write ANOTHER test that requires
18// generalising — the laws create pressure to write more tests
19
20// Step 3: Write next failing test
21test('formats EUR amount with € symbol', () => {
22 expect(formatCurrency(29.99, 'EUR')).toBe('€29.99');
23}); // FAILS — hardcoded '$49.99' does not match
24
25// Step 4: Generalise implementation
26function formatCurrency(amount: number, currency: string): string {
27 return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
28}
29// Both tests passThe three laws sound rigid but they produce two outcomes: you always know why you wrote every line of code (there's a failing test requiring it), and the test suite has complete coverage by construction.
TDD drives design improvements through a simple mechanism: if code is hard to test, the design has a problem. Hard-to-test code is almost always code with too many responsibilities, too many dependencies, or too much coupling.
1// HARD TO TEST — design problem: function does too much
2async function createUserAndSendWelcomeEmail(email: string, password: string) {
3 // Validation, DB insert, password hashing, email sending — all in one
4 // To test this, you need a real database AND a real email server
5 // OR you mock 8 different things
6}
7
8// TDD forces you to feel the pain immediately — this function is painful
9// to write tests for, which signals you should split it.
10
11// EASY TO TEST — design improvement: separated responsibilities
12function validateUserInput(email: string, password: string): ValidationResult {
13 // Pure function: no I/O, easy to test
14}
15
16async function hashPassword(password: string): Promise<string> {
17 // One responsibility, easy to test
18}
19
20async function saveUser(user: NewUser, repo: UserRepository): Promise<User> {
21 // Injected repository: easy to mock in tests
22}
23
24async function sendWelcomeEmail(to: string, emailService: EmailService): Promise<void> {
25 // Injected service: easy to mock in tests
26}
27
28// Each function is independently testable
29// TDD discovered this design by making the pain of testing the original obviousThis is why developers who practice TDD consistently report better code architecture: the tests act as continuous feedback on design quality. Functions that are difficult to test are redesigned before they accumulate technical debt.
The most common objection to TDD is that it takes more time. This is true for the first few hours. Over months, it inverts.
1Debugging time breakdown (typical project without TDD):
2Week 1: Write feature (3h) + debug later (0h) = 3h
3Week 4: Related change breaks feature, debug (2h) = 2h overhead
4Week 8: Another change, more debugging (1h) = 1h overhead
5Total: 3h feature + 3h debugging = 6h
6
7With TDD:
8Week 1: Write feature with TDD (5h, includes tests) = 5h
9Week 4: Change breaks test, fix immediately (10 min) = 0.2h overhead
10Week 8: Change breaks test, fix immediately (10 min) = 0.2h overhead
11Total: 5h feature + 0.4h debugging = 5.4h
12
13Breakeven: ~Week 4. After that, TDD saves time every sprint.The more a codebase evolves, the more TDD saves. The debugging avoided in year 2 easily pays for the extra test-writing time in week 1.
1// The 'debugging avoided' is not just time — it is also:
2// - No production incidents from regression bugs
3// - No customer-facing failures traced to untested edge cases
4// - No 2am on-call pages for bugs tests would have caught
5// - Confident refactoring that does not require manual QA regression
6
7// Example: a bug found by a test vs found in production
8// Found by test:
9// Test fails → 5 minutes to diagnose (test name tells you exactly what broke)
10// → 15 minutes to fix → commit
11
12// Found in production:
13// Customer reports issue → 30 minutes to reproduce
14// → 1 hour to trace through logs → 30 minutes to find root cause
15// → 15 minutes to fix → deploy → verify → 2.5 hours total
16
17// One production bug caught by TDD saves the equivalent of writing
18// 3-5 hours of test code. Bugs caught in production are 5-10x more
19// expensive to fix than bugs caught in development.The fastest way to understand TDD is to do it. Here is a complete Red-Green-Refactor cycle for a string utility.
1// Target: implement slugify(text) — converts 'Hello World' to 'hello-world'
2
3// STEP 1: RED — write a failing test
4test('converts text to lowercase with hyphens', () => {
5 expect(slugify('Hello World')).toBe('hello-world');
6});
7// Run: FAIL — slugify is not defined
8
9// STEP 2: GREEN — minimum code to pass
10export function slugify(text: string): string {
11 return text.toLowerCase().replace(/\s+/g, '-');
12}
13// Run: PASS ✅ (3 seconds of implementation)
14
15// STEP 3: RED — next test to drive more behaviour
16test('removes special characters', () => {
17 expect(slugify('Hello, World!')).toBe('hello-world');
18});
19// Run: FAIL — comma and exclamation mark not handled
20
21// STEP 4: GREEN — extend implementation
22export function slugify(text: string): string {
23 return text.toLowerCase()
24 .replace(/[^a-z0-9\s-]/g, '') // remove special chars
25 .replace(/\s+/g, '-') // spaces to hyphens
26 .replace(/-+/g, '-'); // collapse multiple hyphens
27}
28// Run: Both tests PASS ✅
29
30// STEP 5: RED — edge cases
31test('handles leading and trailing spaces', () => {
32 expect(slugify(' Hello World ')).toBe('hello-world');
33});
34
35test('handles multiple spaces', () => {
36 expect(slugify('Hello World')).toBe('hello-world');
37});
38// Run: FAIL on first test (leading/trailing hyphens remain)
39
40// STEP 6: GREEN — handle trimming
41export function slugify(text: string): string {
42 return text.trim().toLowerCase()
43 .replace(/[^a-z0-9\s-]/g, '')
44 .replace(/\s+/g, '-')
45 .replace(/-+/g, '-')
46 .replace(/^-|-$/g, ''); // remove leading/trailing hyphens
47}
48// Run: All 4 tests PASS ✅
49
50// Total time: ~8 minutes, complete specification, full coverageBuild a complete email validator using strict TDD: each test written before its implementation, driven by the three laws, resulting in a fully specified, fully covered utility.
1// Complete TDD session for an email validator
2// Each test was written BEFORE the implementation that makes it pass
3
4import { validateEmail } from './email-validator';
5
6// RED #1
7test('returns invalid for empty string', () => {
8 expect(validateEmail('').valid).toBe(false);
9});
10// GREEN: export function validateEmail(e: string) { return { valid: false }; }
11
12// RED #2 — forces generalisation
13test('returns valid for well-formed email', () => {
14 expect(validateEmail('[email protected]').valid).toBe(true);
15});
16// GREEN: add regex check
17
18// RED #3
19test('returns invalid when @ is missing', () => {
20 expect(validateEmail('aliceexample.com').valid).toBe(false);
21});
22
23// RED #4
24test('returns invalid when domain is missing', () => {
25 expect(validateEmail('alice@').valid).toBe(false);
26});
27
28// RED #5
29test('returns invalid when TLD is missing', () => {
30 expect(validateEmail('alice@example').valid).toBe(false);
31});
32
33// RED #6
34test('returns the normalised email in the result', () => {
35 expect(validateEmail(' [email protected] ').normalised).toBe('[email protected]');
36});
37
38// RED #7
39test('returns error message for invalid email', () => {
40 const result = validateEmail('bad');
41 expect(result.error).toBeDefined();
42 expect(typeof result.error).toBe('string');
43});
44
45// FINAL IMPLEMENTATION (emerged from TDD cycle):
46export interface ValidationResult {
47 valid: boolean;
48 normalised?: string;
49 error?: string;
50}
51
52export function validateEmail(email: string): ValidationResult {
53 if (typeof email !== 'string' || email.trim() === '') {
54 return { valid: false, error: 'Email must be a non-empty string' };
55 }
56 const normalised = email.trim().toLowerCase();
57 const regex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/;
58 if (!regex.test(normalised)) {
59 return { valid: false, error: `'${normalised}' is not a valid email address` };
60 }
61 return { valid: true, normalised };
62}
63// All 7 tests pass. Function perfectly covers its specification.
64// No line of implementation was written without a failing test requiring it.This session demonstrates the three laws in practice: every production code line was written because a failing test required it. The return type (ValidationResult with optional normalised and error) emerged from the tests — it was never pre-designed. The TDD process discovered the interface by asking 'what does the caller need?' before writing implementation. The result: 100% branch coverage by construction, a clean interface, and zero lines of speculative implementation.
The next lesson covers the Red-Green-Refactor cycle in detail: how to write a minimal failing test, write the simplest passing implementation, and refactor aggressively while keeping tests green.
Your progress is saved in this browser, on this device only.