Test-Driven Development
Stage 3 · Testing, Quality & Craft · B.U.I.L.D. letter: D
You already know how to build things fast. TDD is how you build things that stay fast — where adding feature #50 feels as confident as adding feature #1.
⚠️ The vibe trap
When you're in flow, writing code first and tests later feels natural — you build a thing, it works in the browser, you ship it. The problem shows up three weeks later when you change one function and silently break two others you'd forgotten were connected. Untested vibe code accumulates invisible debt: each new feature is a leap of faith over a growing gap between what the code does and what you think it does. TDD flips the order — and that flip changes everything about how you design.
🔴 Red: Write the failing test first
Before a single line of implementation exists, you write a test that describes the behavior you want. It must fail. A passing test before any code means your test is wrong.
// price-calculator.test.js — RED (no implementation yet)
import { calculatePrice } from './price-calculator.js';
test('full price with no discount', () => {
expect(calculatePrice(100, 0)).toBe(100);
});
Run it. You get a module-not-found error or a "calculatePrice is not a function" error. That red failure is the goal. You now have a specification written in code — a precise, runnable description of what you're about to build.
Mental model: The failing test is your compass. It points at exactly what to build next. Without it, you're navigating by vibes.
Why this matters: Writing the test first forces you to use your own API before it exists. You discover awkward interfaces, bad names, and missing parameters before you've invested hours building them.
Common mistake: Writing the test file but making it pass immediately by accident (e.g., the function already existed from a previous attempt). Always run the test suite before coding and confirm the new test is red.
🟢 Green: Write the minimum code to pass
Now write the simplest possible implementation that turns the test green. Not the best code. Not the full feature. The minimum.
// price-calculator.js — GREEN (bare minimum)
export function calculatePrice(basePrice, discountPercent) {
return basePrice;
}
That's it. It ignores the discount entirely, but the first test passes. Commit this mental habit: green as fast as possible, with no extra cleverness. Cleverness lives in the next step.
// price-calculator.test.js — adding the next RED test
test('applies a 20% discount', () => {
expect(calculatePrice(100, 20)).toBe(80);
});
Run the suite — first test still green, new test red. Good. Now extend the implementation:
// price-calculator.js — GREEN for both tests
export function calculatePrice(basePrice, discountPercent) {
const discount = basePrice * (discountPercent / 100);
return basePrice - discount;
}
Both green. Ship nothing yet — the third step is where the code earns its keep.
Mental model: Think of green as proof of life. You've confirmed the behavior exists. Now you can make it good.
Why this matters: Keeping steps small means each failure has a tiny blast radius. You always know exactly which change broke something because you changed almost nothing.
Common mistake: Writing too much in the green step — adding validation, edge cases, and logging before you have tests covering them. Every line without a test is a line you're trusting on faith.
♻️ Refactor: Clean up with a safety net
Now that tests are green, you can reshape the code confidently. The tests catch any regression immediately.
// price-calculator.js — REFACTOR: clearer intent, same behavior
export function calculatePrice(basePrice, discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError(`Discount must be 0–100, got ${discountPercent}`);
}
const multiplier = 1 - discountPercent / 100;
return parseFloat((basePrice * multiplier).toFixed(2));
}
Then add tests that drove the edge-case handling you just added:
// price-calculator.test.js — covering the new guard
test('rounds to 2 decimal places', () => {
expect(calculatePrice(99.99, 10)).toBe(89.99);
});
test('throws on invalid discount', () => {
expect(() => calculatePrice(100, -5)).toThrow(RangeError);
expect(() => calculatePrice(100, 101)).toThrow(RangeError);
});
Run the suite — all green. The refactored version is cleaner, handles real-world data (floating point!), and every line of it is covered.
Mental model: Refactor means "change structure without changing behavior." The tests are your proof that behavior stayed the same.
Why this matters: Without tests, refactoring is gambling. With tests, it is engineering. This is the step that separates code you're proud of from code you're afraid to touch.
Common mistake: Treating refactor as optional. If you skip it every cycle, the codebase gets messy faster under TDD than without it, because you wrote minimal/hacky green code and never cleaned it up.
🧠 TDD improves design — here's why
When you write the test first, you're forced to answer: "What does calling this code feel like from the outside?" Pure, well-designed functions are easy to test. Tangled, side-effect-heavy functions are painful to test. That pain is feedback — TDD surfaces bad design before you've built a castle on a swamp.
TDD works best for:
- Pure logic functions (calculators, validators, formatters, parsers)
- Business rules with clear inputs and outputs
- Anything where "does it work?" has a crisp yes/no answer
TDD is overkill for:
- Exploratory UI prototypes (test after you've found the shape)
- One-off data migration scripts you'll delete tomorrow
- Third-party integrations where you're learning the API as you go
The skill is knowing which mode you're in.
🤝 TDD with AI: you write the test, AI writes the code, tests judge it
This is the power combo. You are the requirements author; AI is a fast implementer; the test suite is the impartial judge.
- Write a precise failing test that pins down the behavior you want.
- Paste the test into your AI chat: "Make this test pass. Only change
price-calculator.js." - Copy the suggested implementation back. Run the tests.
- If green: refactor together. If red: share the failure output with AI and iterate.
AI-generated code without a test is a guess. AI-generated code that passes your test is a verified answer. The test is the spec; AI is the compiler that reads specs.
🛠️ Your mission
Pick one small pure function in your app — a price formatter, a username validator, a score multiplier, a discount rule. It should have at least 3 distinct behaviors (happy path + at least 2 edge cases).
Work through three complete red-green-refactor cycles:
- Cycle 1 — happy path (normal input, expected output)
- Cycle 2 — edge case (zero, empty string, boundary value)
- Cycle 3 — error case (invalid input throws or returns a safe default)
After each green, refactor at least one thing (a clearer variable name counts). Run the full suite after every change. Use AI to help implement green — but you write the tests.
✅ You're done when…
- Your test file passes the Code Review Rubric — three separate red-green-refactor cycles are visible in your commit history or comments
- Every cycle has a distinct, clearly named test that was written before the implementation
- The refactor step in at least one cycle changed structure without breaking any test
- You ran the suite after every single edit (no "I'll run it at the end")
- An AI helped with at least one green step, and the tests validated its output
➡️ Next: Integration & End-to-End Tests. Build It Right, Or Don't Build It At All. 🏛️