Software Engineering

Test-Driven Development

A design technique where tests are written before implementation, driving clean API design, living documentation, and a fast regression safety net.

⏱ 10 min read

What it is

Test-Driven Development (TDD) is a software development discipline in which tests are written before the code that makes them pass. The cycle has three steps: Red — write a failing test that describes the desired behaviour; Green — write the minimum code to make the test pass; Refactor — improve the design while keeping all tests green. This short feedback loop — typically under a minute — keeps developers anchored to verifiable requirements and produces a growing suite of automated tests as a by-product.

TDD is primarily a design technique, not a testing technique. Writing the test first forces you to think from the caller's perspective: what interface do I need, what inputs and outputs make sense, what should happen in error cases? This outside-in thinking produces cleaner APIs and smaller, more focused units. The tests are a pleasant side effect — a living specification that documents what the system does and detects regressions automatically.

Why it exists

Before TDD, testing was typically done after implementation, often by a separate QA team. This creates a feedback loop measured in days or weeks — by the time a bug is found, the developer has context-switched to other work. Post-hoc testing also tends to validate the implementation rather than the specification, because the tests are written knowing what the code does. The result is tests that pass when the code is wrong in subtle ways.

TDD was popularised by Kent Beck as part of Extreme Programming (XP) and later formalised in his book Test-Driven Development: By Example. It addresses the root cause of untestable code — if you write tests first, you cannot create classes that are impossible to instantiate in isolation. The discipline forces good design: if writing the test is painful, the design needs work. This makes TDD a powerful feedback mechanism for SOLID principle violations, particularly SRP and DIP.

When to use

  • Business logic with complex rules, edge cases, and multiple code paths that require confident specification.
  • APIs and libraries where the caller interface needs to be designed from the consumer's perspective.
  • Refactoring existing code — write characterisation tests first to establish a safety net, then refactor.
  • Bug fixing — write a failing test that reproduces the bug before fixing it, ensuring the fix is permanent.
  • Collaborative development where tests serve as executable documentation for team members.
  • Any code that will live in production longer than a few weeks and needs ongoing maintenance.

When not to use

  • Exploratory / spike work: When you are discovering the problem space, writing tests first slows exploration. Write tests after the spike, or discard the spike and rebuild TDD.
  • UI layout and visual design: Pixel-perfect visual output is better validated by visual regression tools or manual review than unit tests.
  • Heavily I/O-bound integration code: Tests that require database, network, or filesystem setup are slow; TDD's tight loop breaks down. Use integration tests as a separate phase.
  • Legacy codebases with no seams: TDD requires testable units; in code with no dependency injection points, writing tests first is impractical without first creating seams.

Typical architecture


TDD CYCLE
─────────
  ┌──── RED ──────────────────────────────────────────┐
  │  Write a failing test                              │
  │  @Test void should_calculate_tax_for_uk_resident() │
  │  → Compilation fails (class doesn't exist yet)    │
  └───────────────────────────────────────────────────┘
              ↓
  ┌──── GREEN ─────────────────────────────────────────┐
  │  Write minimal code to pass the test               │
  │  class TaxCalculator { double calc(..){ return 0;} │
  │  → Hardcode if needed to get green quickly         │
  └───────────────────────────────────────────────────┘
              ↓
  ┌──── REFACTOR ──────────────────────────────────────┐
  │  Improve design without changing behaviour         │
  │  Extract method, rename, remove duplication        │
  │  → All tests remain green throughout               │
  └───────────────────────────────────────────────────┘

TEST PYRAMID
────────────
         ▲
        /E2E\        few, slow, high-confidence
       /──────\      (Selenium, Cypress, Playwright)
      /  Integ  \    moderate count, medium speed
     /────────────\  (Spring @SpringBootTest, Supertest)
    /    Unit      \ many, fast, isolated
   /────────────────\ (JUnit, Jest, pytest — milliseconds)

PROPERTY-BASED TESTING (fast-check / Hypothesis)
─────────────────────────────────────────────────
  fc.assert(fc.property(
    fc.integer(), fc.integer(),
    (a, b) => add(a, b) === add(b, a)  // commutativity
  ))
  → Engine generates hundreds of random inputs to find edge cases

Pros and cons

Pros

  • Produces testable designs automatically — untestable code cannot be built test-first.
  • Regression safety net grows with the codebase, enabling fearless refactoring.
  • Living documentation — tests describe intended behaviour in executable form.
  • Design feedback is immediate — painful tests signal design problems before they ossify.
  • Dramatically reduces debugging time — failures are caught within minutes of introduction.

Cons

  • Higher upfront investment — initial velocity feels slower, especially learning the discipline.
  • Slow adoption in legacy codebases — adding tests to code without seams requires significant refactoring first.
  • Risk of testing implementation details rather than behaviour — brittle tests that break on refactoring.
  • Mocking overuse — over-mocking produces tests that pass even when the real collaboration is broken.
  • False confidence — high unit test coverage with no integration tests misses integration bugs.

Implementation notes

Test pyramid ratios: Google's widely-cited guideline is 70% unit tests, 20% integration tests, 10% end-to-end tests. Unit tests should be sub-millisecond; the entire unit test suite for a module should finish in seconds. Integration tests (testing code against real databases, message queues, or HTTP services) should be isolated using test containers (Testcontainers library) to avoid shared state. E2E tests are the most expensive to maintain and should cover only the most critical user journeys.

Property-based testing complements example-based TDD by generating hundreds of random inputs to find edge cases you would not think to write. Tools like fast-check (JavaScript), Hypothesis (Python), and jqwik (Java) allow you to express invariants (commutative addition, round-trip serialisation, idempotent operations) that are then automatically stress-tested. Mutation testing validates your test suite's quality by introducing deliberate bugs (mutations) into the production code and verifying that tests catch them. PIT (Java) and Stryker (JavaScript/TypeScript) are the leading tools. A mutation score below 80% indicates insufficient test coverage or tests that pass regardless of logic.

Common failure modes

  • Testing the mock, not the code: Tests stub every dependency so heavily that the production code is never actually called — green tests, broken behaviour.
  • Test as afterthought: Writing tests after the fact produces tests that confirm what the code does rather than what it should do, missing requirements-level bugs.
  • Skipping the refactor step: The "green" phase is not the end; code written to pass a test quickly is often ugly. Skipping refactor accumulates test-debt.
  • Fragile tests: Testing internal methods or private state rather than public behaviour; every refactoring breaks the tests even when behaviour is unchanged.
  • Slow test suite: Tests that hit the filesystem or network in the unit layer make the TDD loop too slow to be useful; developers stop running the suite.

Decision checklist

  • Are you writing the test before the implementation code, not after?
  • Does each test have a single assertion or a single behaviour it validates?
  • Can each unit test run in complete isolation without network, filesystem, or database access?
  • Is your test pyramid balanced — many fast unit tests, fewer slower integration tests?
  • Are you testing observable behaviour (outputs and state changes) rather than implementation details (internal method calls)?
  • Do you have mutation testing in CI to verify the test suite actually catches regressions?

Example use cases

  • Tax calculation engine: TDD excels at specifying complex multi-jurisdiction tax rules where edge cases are specified as failing tests before any calculation logic is written.
  • Payment processing: Each payment state transition (authorised → captured → refunded) is specified as a test before the state machine is implemented.
  • Parser or compiler: Property-based tests verify round-trip invariants (parse(serialise(x)) === x) across thousands of auto-generated inputs.
  • SOLID Principles — TDD naturally enforces SOLID; code that is hard to test first almost always violates SRP or DIP.
  • Code Review Best Practices — Tests serve as objective review material; reviewers can run the test suite to verify claimed behaviour.
  • Refactoring Patterns — The refactor step of TDD requires a safety net; TDD builds that net as you go.

Further reading