🔁 Test-Driven Development Basics
What if you wrote the test before the code? It sounds backwards, but this one inversion — test first, then just enough code to pass — is one of the most influential ideas in software craftsmanship. TDD turns testing from a chore you do afterward into a design tool that shapes better code as you write it.
Week 3 · Day 5 (Friday: Testing Fundamentals) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the Red-Green-Refactor cycle and why each phase matters
- Write a failing test first and make it pass with the minimum code
- Build a small feature end to end using TDD (a String Calculator)
- Apply the core techniques: fake it, triangulate, and obvious implementation
- Recognize where TDD shines and where it fights you
- Avoid the common TDD anti-patterns
Estimated Time: 70 minutes
Practice: Drive a String Calculator and a Password Validator entirely from failing tests.
In This Lesson
Turning Development Upside Down
Imagine building a house by first writing the inspection checklist, then constructing each part to pass a specific inspection. You'd never build a wall the inspector didn't ask for, and you'd know the moment something failed code. That's the spirit of Test-Driven Development: you write a test describing the behavior you want, watch it fail, then write only the code needed to make it pass.
TDD was popularized by Kent Beck as part of Extreme Programming. Its central claim is surprising: TDD is less about testing and more about design. By forcing you to use your code before it exists, it nudges you toward small, focused, decoupled units — the same qualities that made the tests in our last lesson easy to write.
📖 Test-first vs. test-after
In test-after development you build the feature, then bolt on tests to protect it. In test-first (TDD) the test comes first and specifies the behavior. Same tests may result, but the order changes how you design: test-first pressure keeps units small and their interfaces clean.
The Red-Green-Refactor Cycle
TDD runs on a tight three-phase loop that you repeat for every small increment of behavior. The names come from the color your test runner shows.
Write a failing test] --> B[🟢 GREEN
Minimal code to pass] B --> C[🔵 REFACTOR
Improve, keep green] C --> A
🔴 Red — write a failing test
- Write a test for the next small piece of behavior you want
- Run it and watch it fail — this proves the test can fail
- A test that never fails is a test that checks nothing
🟢 Green — make it pass
- Write the minimum code to turn the test green
- Ugly is fine right now; correctness of the test is the only goal
- Resist building features no test has asked for
🔵 Refactor — improve the design
- Now clean up: remove duplication, rename, extract methods
- Run the tests after every change to stay green
- Your passing tests are the safety net that makes this safe
💡 Keep the steps tiny
Beginners often try to leap. TDD works best in baby steps — sometimes the "minimum code" is embarrassingly simple, like return 0;. That's expected. Each lap of the cycle should take seconds to a couple of minutes.
Why Bother? The Benefits
TDD asks for discipline, and in return it gives you more than a pile of tests:
- Better design: using an API before it exists forces a clean, minimal interface
- Built-in coverage: because code only exists to pass a test, nearly everything is covered
- Living documentation: the tests describe exactly what the code is supposed to do
- Fast feedback: bugs surface within seconds, while the context is fresh
- Fearless refactoring: a green suite lets you restructure without anxiety
- Focus: you work on one requirement at a time instead of drowning in scope
TDD in Action: A String Calculator
The best way to feel TDD is to watch it build something. The "String Calculator" is a classic kata — a small exercise practiced for skill. Our add method takes a string of numbers and returns their sum, growing one requirement at a time.
Requirements (we'll take them one at a time)
- An empty string returns
0 - A single number returns that number
- Two comma-separated numbers return their sum
- Any count of numbers is supported
- Newlines are allowed as separators too
- A custom delimiter can be declared on the first line
Step 1 — Empty string returns 0
// 🔴 RED — write the failing test first
import { StringCalculator } from './stringCalculator';
describe('StringCalculator', () => {
test('returns 0 for an empty string', () => {
const calc = new StringCalculator();
expect(calc.add('')).toBe(0);
});
});
// Run it: FAILS — StringCalculator isn't defined yet.
// 🟢 GREEN — the simplest thing that could possibly pass
export class StringCalculator {
add(numbers) {
return 0; // yes, really — no test demands more yet
}
}
// Run it: PASSES. 🔵 REFACTOR — nothing to clean up.
Step 2 — A single number returns itself
// 🔴 RED
test('returns the number for a single number', () => {
expect(new StringCalculator().add('1')).toBe(1);
});
// FAILS — currently always returns 0.
// 🟢 GREEN
export class StringCalculator {
add(numbers) {
if (numbers === '') return 0;
return parseInt(numbers, 10);
}
}
// 🔵 REFACTOR — an empty string is falsy, so simplify the guard
export class StringCalculator {
add(numbers) {
if (!numbers) return 0;
return parseInt(numbers, 10);
}
}
Step 3 — Two numbers return their sum
// 🔴 RED
test('returns the sum of two comma-separated numbers', () => {
expect(new StringCalculator().add('1,2')).toBe(3);
});
// FAILS — parseInt('1,2', 10) is 1.
// 🟢 GREEN — handle the comma
add(numbers) {
if (!numbers) return 0;
if (numbers.includes(',')) {
const [a, b] = numbers.split(',');
return parseInt(a, 10) + parseInt(b, 10);
}
return parseInt(numbers, 10);
}
// 🔵 REFACTOR — one code path handles one OR many
add(numbers) {
if (!numbers) return 0;
return numbers
.split(',')
.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
Step 4 — Any count of numbers
// 🔴 RED
test('sums any amount of numbers', () => {
expect(new StringCalculator().add('1,2,3,4,5')).toBe(15);
});
// Run it: PASSES immediately — the refactor in step 3 already handled this!
// (A green bar on a new test is a small gift. Keep it as a regression guard.)
✅ When a new test passes for free
Sometimes your last refactor was general enough that the next requirement already works. That's a sign of good design — keep the test anyway. It documents the requirement and guards against future regressions.
Step 5 — Newlines as separators
// 🔴 RED
test('allows newlines between numbers', () => {
expect(new StringCalculator().add('1\n2,3')).toBe(6);
});
// FAILS — parseInt('1\n2', 10) is 1.
// 🟢 GREEN + 🔵 REFACTOR — split on comma OR newline with a regex
add(numbers) {
if (!numbers) return 0;
const delimiter = /[,\n]/;
return numbers
.split(delimiter)
.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
Step 6 — Custom delimiters
// 🔴 RED — a leading "//;\n" line declares ';' as the delimiter
test('supports a custom delimiter', () => {
expect(new StringCalculator().add('//;\n1;2')).toBe(3);
});
// FAILS.
// 🟢 GREEN — detect and strip the header line
add(numbers) {
if (!numbers) return 0;
let delimiter = /[,\n]/;
let body = numbers;
if (numbers.startsWith('//')) {
const newline = numbers.indexOf('\n');
delimiter = numbers.substring(2, newline); // the chosen delimiter
body = numbers.substring(newline + 1); // the numbers after it
}
return body
.split(delimiter)
.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
// 🔵 REFACTOR — extract intent-revealing methods
export class StringCalculator {
add(numbers) {
if (!numbers) return 0;
const { delimiter, body } = this.#parse(numbers);
return this.#sum(body.split(delimiter));
}
#parse(input) {
if (!input.startsWith('//')) return { delimiter: /[,\n]/, body: input };
const newline = input.indexOf('\n');
return {
delimiter: input.substring(2, newline),
body: input.substring(newline + 1),
};
}
#sum(parts) {
return parts.reduce((sum, n) => sum + parseInt(n, 10), 0);
}
}
Notice how the design emerged. We never sat down to architect a parser; the sequence of failing tests pulled a clean, small class into existence, one behavior at a time.
Core TDD Techniques
Kent Beck describes a few reliable moves for the Green phase. Knowing them tells you how big a step to take.
1. Fake it 'til you make it
Return a hardcoded value to go green fast, then generalize as more tests force your hand.
// First test only needs fib(1) === 1
function fibonacci(n) {
return 1; // fake it
}
// After several tests force the real relationship:
function fibonacci(n) {
if (n <= 2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
2. Triangulation
When one example lets you cheat with a constant, add a second example. Two data points force the real formula.
test('multiplies 2 x 3', () => {
expect(multiply(2, 3)).toBe(6);
});
test('multiplies 3 x 4', () => {
expect(multiply(3, 4)).toBe(12); // now you can't just "return 6"
});
function multiply(a, b) {
return a * b; // the second example triangulated the generalization
}
3. Obvious implementation
When the real code is trivial and you're confident, just write it. TDD doesn't require artificial baby steps when the answer is plain.
test('reports an empty array as empty', () => {
expect(isEmpty([])).toBe(true);
});
function isEmpty(array) {
return array.length === 0; // obvious — no need to fake it
}
💡 Match the step size to your confidence
Cruising and sure? Use obvious implementation. Uncertain, or the test is red in a surprising way? Shrink the step — fake it, then triangulate. TDD is a throttle you can dial up and down.
When to Use TDD
TDD is a powerful default, but it isn't a religion. It pays off most when behavior is well-defined and testable in isolation.
| Great fit for TDD | Harder for TDD |
|---|---|
| Pure functions with clear inputs/outputs | Pixel-perfect UI layout and visual style |
| Business rules and calculations | Exploratory spikes where the goal is unknown |
| Reproducing and fixing a reported bug | Legacy code not designed to be tested |
| Designing an API or algorithm | Thin glue over external systems |
✅ TDD for bug fixes
Even if you don't do TDD full-time, use it for bugs. Write a failing test that reproduces the bug first. When it turns green, you've both fixed the problem and left a permanent regression guard so it can never silently return.
Anti-Patterns to Avoid
1. Writing the test after the code
Retrofitting a test onto finished code is just test-after wearing a TDD costume. You lose the design pressure, and hard-to-test code stays hard to test. Let the red bar come first.
2. Testing implementation details
// ❌ Anti-pattern: asserting on private internals
test('sets the internal cache', () => {
const service = new DataService();
service.fetchData();
expect(service._cache).toBeDefined(); // brittle — breaks on any refactor
});
// ✅ Better: assert on observable behavior
test('serves cached data on the second call', () => {
const api = { get: jest.fn().mockResolvedValue('data') };
const service = new DataService(api);
return service.fetchData()
.then(() => service.fetchData())
.then(() => expect(api.get).toHaveBeenCalledTimes(1));
});
3. Writing more code than the test demands
// The current test only needs add():
test('adds two numbers', () => {
expect(calculator.add(2, 3)).toBe(5);
});
// ❌ Over-implementation — untested, speculative code
class Calculator {
add(a, b) { return a + b; }
subtract(a, b) { return a - b; } // no test asked for this
multiply(a, b) { return a * b; } // YAGNI — "You Aren't Gonna Need It"
}
⚠️ YAGNI
"You Aren't Gonna Need It." Speculative code you add "just in case" is untested, unrequested, and a future maintenance cost. In TDD, if no failing test needs it, you don't write it yet.
Practice & Quiz
🏋️ Exercise 1: TDD a Password Validator (first step)
Goal: Start the Red-Green cycle for a validator that returns { isValid, errors }. Write only the first failing test (minimum length ≥ 8) and the minimum code to pass it.
💡 Hint
Red: assert that validate('Pass1!') gives isValid: false and an errors array containing the length message. Green: push that one message when password.length < 8.
✅ Solution
// 🔴 RED
import { PasswordValidator } from './passwordValidator';
test('rejects a password shorter than 8 characters', () => {
const result = new PasswordValidator().validate('Pass1!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters');
});
// 🟢 GREEN
export class PasswordValidator {
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return { isValid: errors.length === 0, errors };
}
}
🏋️ Exercise 2: Refactor to a rules table
Goal: After adding tests for uppercase, lowercase, digit, and special-character requirements, refactor the validator so rules live in a data-driven array — without breaking any test.
✅ Solution
export class PasswordValidator {
#rules = [
{ test: p => p.length >= 8, msg: 'Password must be at least 8 characters' },
{ test: p => /[A-Z]/.test(p), msg: 'Password must contain at least one uppercase letter' },
{ test: p => /[a-z]/.test(p), msg: 'Password must contain at least one lowercase letter' },
{ test: p => /\d/.test(p), msg: 'Password must contain at least one number' },
{ test: p => /[!@#$%^&*(),.?":{}|<>]/.test(p), msg: 'Password must contain at least one special character' },
];
validate(password) {
const errors = this.#rules
.filter(rule => !rule.test(password))
.map(rule => rule.msg);
return { isValid: errors.length === 0, errors };
}
}
// Because the tests describe behavior (not the loop), they stay green
// through this structural change — that's refactoring with confidence.
🎯 Quick Quiz
Question 1: What is the correct order of the TDD cycle?
Question 2: In the Green phase, how much code should you write?
Question 3: Why must you watch the test fail in the Red phase?
Best Practices & Pitfalls
✅ Do
- Write one failing test at a time and watch it go red first
- Write the minimum code to pass, then refactor on green
- Keep laps tiny — seconds to a couple of minutes each
- Use TDD to reproduce and lock in bug fixes
- Let the tests describe behavior, so refactors stay safe
❌ Don't
- Write code before its test — that's test-after in disguise
- Assert on private fields or internal implementation
- Add speculative features no test requires (YAGNI)
- Skip the refactor step — green-but-messy code compounds
- Try to force TDD onto pure visual/exploratory work
⚠️ The refactor step is not optional
It's tempting to stop the moment the bar turns green and move on. But the whole payoff of TDD is that the green suite lets you improve the code safely. Skip refactoring and you accumulate the same mess TDD was meant to prevent — you'll just have tests watching it grow.
Summary
🎉 Key Takeaways
- TDD writes the test first, making testing a design tool, not an afterthought
- The cycle is Red → Green → Refactor, repeated in tiny steps
- In Green, write the minimum code to pass; save polish for Refactor
- Core techniques: fake it, triangulate, and obvious implementation — match the step to your confidence
- Clean design emerges from the sequence of tests, as our String Calculator showed
- Avoid the anti-patterns: test-after, testing internals, and YAGNI over-building
"TDD is not about testing — it's about design and development guided by tests."
📚 Additional Resources
- Jest — Getting Started
- Jest — Watch Mode & Plugins (the TDD workflow)
- MDN — Introduction to automated testing
🚀 What's Next?
You've now got the full testing toolkit: Jest, solid unit tests, and the TDD cycle to drive them. Time to put it all together on something real. In the weekend project you'll build a modern JavaScript application with Webpack, Babel, and a full test suite — applying everything from this week.
🎉 Red, Green, Refactor — you've got the rhythm!
Letting tests lead the design is a habit that separates hobby code from professional code. Carry it into the weekend project.