Test-Driven Development (TDD)
Test-Driven Development is a workflow where you write a failing test first, then write just enough code to pass it, then clean up — using the tests to drive the design of your code. Done well, it produces better-factored interfaces, fewer regressions, and a suite of executable specifications that document what the code is supposed to do.
TDD is a tool, not a religion. It shines for logic you can express as clear inputs and outputs, and it's awkward for exploratory or UI-heavy work. The value isn't the tests alone — it's the design pressure that writing tests first applies to your code.
TL;DR
- The loop: Red → Green → Refactor.
- Focus on behavior, not implementation details.
- Keep feedback fast: small tests, small steps.
- TDD is a tool — use it where it improves outcomes.
Quick Example
The cycle in miniature — write the failing test before the function exists:
The Red–Green–Refactor Cycle
- Red — write a failing test for one tiny behavior. This confirms the test actually tests something (a test that's never red proves nothing).
- Green — write the simplest code to pass. Resist premature generalization.
- Refactor — clean up structure, naming, and duplication while keeping tests green.
Repeat in small steps. Each loop should be quick.
What TDD Is Really Optimizing
Writing the test first forces you to use your code before you build it, which pushes you to:
- Design interfaces that are easy to call.
- Avoid hidden dependencies (you have to make them injectable to fake them).
- Keep code modular and decoupled.
It also turns requirements into executable specifications that stay in sync with the code.
Practical Guidelines
- Write small tests — one concept, descriptive name, minimal setup.
- Test behavior, not internals — assert on outputs and visible effects, not private state.
- Use dependency injection — if a function depends on time, randomness, network, or a database, pass those in (or wrap them behind interfaces) so tests can supply fakes.
Test structure conventions
- Arrange–Act–Assert — setup, run, check.
- Given–When–Then — the same shape in BDD-style specs.
When TDD Fits (and When It's Harder)
Great fit: domain logic, pure functions, validation and parsing, business rules, and bug fixes (write a test that reproduces the bug first).
Harder: UI-heavy code without good testing seams, deeply async/distributed workflows, and exploratory prototypes. In those cases, lean on higher-level integration or E2E tests rather than forcing unit-level TDD.
Best Practices
- Take small steps — tiny red→green→refactor loops keep feedback fast and debugging trivial.
- Refactor on green — never refactor and change behavior at the same time.
- Reproduce bugs with a test before fixing them — it documents the fix and prevents regressions.
- Let tests drive design — if something is hard to test, that's feedback about the design, not the test.
Common Mistakes
Brittle tests that mirror implementation
Over-mocking
FAQ
Do I have to write tests before code to "do TDD"?
Yes — that's the defining feature. The failing test first is what creates the design pressure and guarantees the test can actually fail. Writing tests after code is still valuable (it's just testing), but it doesn't drive design the same way and tends to validate whatever you already built rather than question it.
Does TDD slow development down?
It front-loads effort and can feel slower per feature, but it usually pays back through fewer bugs, less debugging, safer refactoring, and living documentation. The net effect depends on context — it's a clear win for complex domain logic and long-lived code, less so for throwaway prototypes. Use it where the payoff is real.
How is TDD different from BDD?
TDD is a developer workflow centered on the red-green-refactor cycle and unit-level design. BDD (Behavior-Driven Development) frames tests as human-readable behavior specs (Given-When-Then), often spanning higher levels and involving non-developers. They're compatible — BDD is essentially TDD with a vocabulary and structure aimed at describing behavior and collaborating on requirements.
What do I do when code is too hard to test?
Treat the difficulty as a design signal. Hard-to-test code usually has hidden dependencies, does too much, or mixes concerns. Refactor toward smaller units with injected dependencies and clear inputs/outputs. The same changes that make code testable (decoupling, single responsibility) also make it better-designed — which is much of TDD's point.
Related Topics
- Unit Testing — The tests TDD produces
- Integration Testing — When unit TDD isn't enough
- Mocking & Stubbing — Faking dependencies
- Jest · Pytest — Frameworks for the loop
- Playwright — E2E for UI-heavy code