AlgoMaster Logo

Test-Driven Development

Medium Priority33 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Test-driven development (TDD) is the practice of writing the test before the code. The developer decides what the code should do, writes a test that asserts that behavior, watches the test fail, then writes just enough code to make it pass. Done in a tight loop, the practice changes the shape of the code produced: smaller functions, clearer interfaces, fewer surprises later.

The Red-Green-Refactor Cycle

The TDD loop has three steps, and the names are vivid enough to remember.

Red. Write a failing test for behavior that doesn't exist yet. The test should fail in a meaningful way: either the function doesn't exist (so the import fails), or it exists but returns the wrong value. A test that passes on the first try is suspicious; it usually means the code already existed, or the test isn't actually checking the intended behavior.

Green. Write the smallest possible amount of code that makes the test pass. Don't add features. Don't generalize. Don't anticipate the next requirement. If the test asserts that apply_discount(100, 10) returns 90, the implementation can be return 90. That looks absurd, but it's the point: green should be the easiest possible path to a passing test.

Refactor. With the test green, clean up the code without changing its behavior. Rename things, extract helpers, remove duplication, simplify expressions. The test stays green throughout. Any regression surfaces immediately.

Then back to Red for the next behavior, and the loop continues.

Each turn of the loop is small. A typical cycle takes a few minutes: write a test (one or two minutes), make it pass (a minute or two), refactor (a minute). The loop should turn dozens of times in an hour. A single cycle taking thirty minutes signals the test is too big or the implementation step is doing too much. Split the next behavior into smaller pieces.

The strict version of TDD has three laws, attributed to Robert C. Martin:

  1. You may not write production code unless it is to make a failing test pass.
  2. You may not write more of a unit test than is sufficient to fail (compilation failures count).
  3. You may not write more production code than is sufficient to pass the currently failing test.

In practice, most developers don't follow the laws this strictly. The spirit is what matters: small steps, tests first, no code without a test asking for it.

Why Write Tests First

The benefit isn't just about having tests at the end (test-after would also produce tests). The act of writing the test first changes the design.

It forces the interface to be defined first. Before the function is written, the code that calls it is written. That call is the first user of the function: it shows the argument order, the return shape, the error behavior. A function whose call site looks awkward in the test usually has an awkward design. Catching that before the function body is written is cheaper than catching it after.

It builds confidence to refactor. Code without tests is hard to change; nobody knows what will break. Code with tests written after the fact tends to test what the code does, not what it should do. Code with tests written first tests the contract committed to before the implementation existed, which is a more honest description of what the code is for.

It prevents the "I'll write tests later" trap. "Later" reliably means "never." Many teams start a project promising to add tests after the first release, and most never do. Writing the test first means the tests exist by the time the code does.

It exposes ambiguity in requirements. Writing a test forces concrete values: this input, this expected output. When the values can't be picked, the requirement isn't well-enough understood to implement. The test pins the requirement down or surfaces the question.

These benefits aren't free. There's an upfront cost (slower at the start because two things are being written at once) and a discipline cost (skipping the test is tempting when "just trying to get this working"). The argument for TDD is that the upfront cost is paid back many times over by the design and confidence benefits, and the discipline becomes habit after enough cycles.

A Worked Example: FizzBuzz

The classic teaching example is FizzBuzz: for numbers 1 through N, print "Fizz" if the number is divisible by 3, "Buzz" if divisible by 5, "FizzBuzz" if divisible by both, and the number itself otherwise. Trivial enough to fit in a single page, rich enough to show every step.

The walkthrough below proceeds step by step, writing the test first each time.

Cycle 1: A Single Number

The smallest possible test: for input 1, return the string "1".

Run pytest. It fails with ImportError: cannot import name 'fizzbuzz' from 'fizzbuzz'. That's the Red step. Now write the minimum code to pass.

Run pytest. Green. The function returns "1" for everything, which is obviously wrong, but the only test we have passes. That's enough to move on.

There's nothing to refactor yet, so the cycle ends and the next behavior comes up.

Cycle 2: A Different Number

Add a test for a different number to force the implementation to actually compute.

Run pytest. The new test fails (fizzbuzz(2) returns "1"). Red. Write the smallest fix.

Both tests pass. Green. Nothing meaningful to refactor.

Cycle 3: Divisible by Three

The interesting case. For input 3, return "Fizz".

Red. The current code returns "3". Add the rule.

All three tests pass. Green. Still nothing screaming for refactoring.

Cycle 4: Divisible by Five

Add the Buzz case.

Red. Add another branch.

Green.

Cycle 5: Divisible by Both

The trickiest case: numbers like 15 that are divisible by both 3 and 5.

Red. The current code returns "Fizz" because the first branch matches. The fix is to check the both case before either single case.

All five tests pass. Green.

The Refactor Step

Look at the code with fresh eyes. The three branches share a structure: build up a result from divisibility rules. One reasonable cleanup is to construct the string by concatenation:

This version handles "FizzBuzz" naturally (both branches match, the strings concatenate), eliminates the magic number 15, and reads as "build the word from rules; if no rules matched, use the number." Run the tests after the refactor. All five still pass. Green.

That's the cycle in miniature. Five tests, five minimum implementations, one refactor at the end. The final code is short and clear, and every line is exercised by a test. If a future maintainer changes the rules (say, adds "Bang" for divisible by 7), the failing test identifies which branch broke.

A few observations about the process.

The tests drive the design. The first version of fizzbuzz returned "1" for everything. That looks wrong, but it was correct for the only test that existed. Each new test forced the code to grow, one rule at a time, with no speculation about features not yet asked for.

The refactor only happened after green. The consolidation waited until all the rules were in place. Refactoring earlier (after Cycle 3 or 4) would have been premature; there wasn't enough information yet to see the right shape.

Every test has one purpose. test_returns_buzz_for_five doesn't check that 3 returns "Fizz" or that 15 returns "FizzBuzz." Each test names a single behavior, which makes failures easy to diagnose. If the refactor had broken the "Fizz" case, only test_returns_fizz_for_three would fail.

A Slightly Bigger Example: A Stack

FizzBuzz is small enough that the design barely matters. A more realistic example is building a Stack class one operation at a time, which shows TDD shaping a class interface.

Cycle 1: An Empty Stack

The first test pins down what a fresh stack looks like.

Red: the class doesn't exist. Write the smallest version.

Green. The code is wrong for any other state, but for an empty stack it's correct, and that's all the tests check.

Cycle 2: Push Increases Size

push needs to do something. The test forces it.

Red: Stack has no push method. State is required.

The implementation grows. The first cycle's "always return True / always return 0" implementation isn't sufficient anymore. The new version adds __init__ and rewrites the two existing methods to look at real state. That's a normal step in TDD: early implementations are intentionally fake, and they get replaced as new tests demand.

Both tests pass. Green.

Cycle 3: Pop Returns the Last Item

Red. Add pop.

Green.

Cycle 4: Pop on an Empty Stack

What should pop do when the stack is empty? A reasonable contract is to raise an error. The test pins it down.

Run the tests. The current pop already raises IndexError (because list.pop() on an empty list does), so this test passes without any change. Green. No refactor needed.

One useful observation: not every test forces a code change. Sometimes a test exists to document and lock in current behavior so a future refactor can't accidentally break it. The test test_pop_raises_when_empty is now a contract: pop raises IndexError on empty, and any refactor that changes that contract will fail the test.

Cycle 5: Peek Without Removing

Red. Add peek.

Green.

The Refactor Step

Look at the class. The implementation is a thin wrapper around a list. Methods don't share logic that could be extracted, names are clear, no duplication stands out. The honest refactor verdict: nothing to refactor. The code is already as clean as it needs to be for the behavior it has.

This is normal. Many cycles end with no refactoring. The refactor step is an opportunity, not an obligation. Forcing a refactor where none is needed adds complexity for its own sake.

The final class:

Five tests, five behaviors, one short class. The interface emerged from the tests: is_empty, size, push, pop, peek. The class wasn't designed up front and then implemented to match a plan. One test came in, then was made to work; the next test came in, then was made to work; the class shape fell out of the process.

That's the part of TDD that takes the most getting used to. There's no sit-down planning step for the class. Tests for the smallest behavior come first, then the next, and the design emerges. It feels backward at first, especially after years of designing top-down. The benefit is that only the code the tests demand gets written, which means no dead code, no speculative features, and no design decisions made without a concrete test case to anchor them.

When TDD Fits

TDD is a tool. Like any tool, it fits some jobs better than others.

Well-specified APIs. When a function's contract is clear (inputs, outputs, error cases) but the implementation isn't obvious, TDD is excellent. The tests define the spec, and the implementation grows to match.

Bug fixes. A high-value use of TDD is fixing bugs. Write a test that reproduces the bug. Run it. It fails (Red). Fix the code. Now it passes (Green). The test stays in the suite forever, protecting against the same bug coming back. Even die-hard test-after developers tend to adopt this form.

Refactoring legacy code. When code that nobody trusts needs to change, TDD provides a way in. Write tests for the current behavior (whatever it is, bugs included), then refactor with the safety net on. Once the refactor is done, decide which behaviors were intentional and which were accidents, and update the tests accordingly.

Algorithm work. Anything with a clear input-output contract (sorting, parsing, encoding, data transformation) suits TDD well. Each edge case becomes a test, the implementation grows to handle them one by one, and the suite serves as a regression check against future changes.

Library and framework code. Code that other code calls (rather than code that humans interact with) has clear interfaces and predictable behaviors. TDD fits naturally because the test is the first user of the API, and the API has to make sense to that user.

When TDD Is Awkward

TDD isn't a fit for every situation. Forcing it where it doesn't belong leads to brittle tests, fake test names, and frustration.

UI work. Building a user interface is partly visual and partly behavioral. The visual part (does this button look right, does the layout feel balanced) doesn't fit into a "write a failing test first" loop. The screen has to be visible. The behavioral part can be tested, but most of the iteration happens through visual inspection, not the test runner.

Exploration and prototyping. When the shape of the thing being built isn't decided yet, writing tests first is putting the cart before the horse. The tests would assert behavior that hasn't been settled. The right mode for exploration is the REPL: try things, see what works, throw it away, try again. Once the shape is clear, tests for the parts worth keeping come next.

Code where the interface is the hard part. Some problems are 90% interface design and 10% implementation. TDD doesn't help much here because the tests are easy once the interface is decided, and deciding on the interface is the actual work. Sketching the API on paper and iterating with a colleague is usually more productive than writing tests.

Integration with external services. Code that talks to a database, an HTTP API, or a message queue is hard to TDD without heavy mocking. Mocks let tests run, but the tests then verify the conversation with the mock, not the real integration. The right pattern is usually: TDD the pure logic with unit tests, then add a smaller number of integration tests (run less often, sometimes only in CI) that exercise the real service.

Hard real-time or hardware-driven code. Drivers, embedded systems, anything tightly coupled to specific hardware timing. TDD assumes a tight feedback loop on a normal computer. When the code only runs meaningfully on the real device, the loop breaks.

The honest framing: TDD is a good default for most application code, but it's not a religion. Use it where it helps, skip it where it hurts. A well-tested codebase is better than a poorly-tested one regardless of which order the tests and code were written.

Common Anti-Patterns

A few mistakes show up so often they deserve names.

Testing implementation, not behavior. A test that checks "the function calls method X then method Y on the helper object" couples the test to the implementation. Refactor the function to do the same work differently, and the test breaks even though the behavior is identical. The fix: assert on what the function returns or what observable state it changes, not on how the work was done.

Brittle mocks. Tests that mock every collaborator, then assert on the exact arguments of every mock call, fall apart at the slightest refactor. The fix is the same as the previous point: mock only at real boundaries (network, time, randomness), and assert on outcomes, not interactions.

Skipping the refactor step. TDD with no refactor is just "test-first." The protection is there, but the code stays in whatever ugly shape was minimum to pass each test. The refactor step is where the value gets collected: small simplifications add up over hundreds of cycles. Skipping it is the most common way to end up with a passing test suite over a messy codebase.

Making the test pass by changing the test. When a test fails, changing the expected value to match the actual output is tempting. Sometimes that's correct (the test was wrong). Often it's wrong (the test was right; the code is broken). Whenever rewriting an expected value comes up, stop and ask which side is correct. If unclear, dig into the code first.

Tests that depend on each other. TDD encourages small, focused tests, but tests that share state through globals, files, or class attributes are easy to write. The result: order-dependent tests that pass alone and fail together (or vice versa). The fix is the same as for any tests: each test sets up its own state and cleans up after itself, or uses fixtures to manage state explicitly.

Writing too many tests at once. "I'll just write all the test cases now, then implement them all together." This breaks the loop. The whole point of TDD is the rhythm: one test, one implementation, one refactor, next test. Writing five tests before the first implementation means five red bars at once, and the moment-by-moment feedback that makes the practice work is gone.

A test suite that runs slowly turns TDD from a fast feedback loop into a chore. When a single cycle takes more than ten seconds of waiting (test runner startup plus test execution), running it on every change stops happening. Keep tests fast, isolate from databases and network, and use pytest's --lf or -x flags to narrow the run when iterating on one feature.

Combining TDD with pytest

TDD doesn't require any special framework. The cycle works with unittest, pytest, plain assert, or any other test runner. That said, pytest has a few features that make the rhythm faster.

`-x` for stop-on-first-fail. During the Red step, only one test matters. pytest -x stops at the first failure, avoiding a wall of downstream failures.

`--lf` for last-failed-only. After fixing the failing test, pytest --lf reruns only the tests that failed last time. If they all pass, the suite is green. This is faster than running the whole suite during iteration.

`-k EXPR` for name filtering. With hundreds of tests but current iteration on the discount logic, pytest -k discount runs only the tests whose names contain "discount". Pair with -x to focus on one failing test at a time.

Fixtures for shared setup. When several tests need the same starting state (a fresh Cart, a temporary database), a pytest fixture writes the setup once and injects it into every test that needs it.

Parametrization for related cases. When adding tests for a function that handles many similar cases (every divisor in FizzBuzz, every input for a parser), @pytest.mark.parametrize expresses the cases as data rather than as separate test functions. Parametrization can speed up the TDD cycle once the shape is known: add a row to the parametrize list, run, fix, repeat.

A workable daily loop combining these:

Tools like pytest-watch automate the "re-run on save" part, dropping the loop to sub-second feedback. The faster the loop, the more cycles per hour, and the more value from the practice.

A Realistic Worked Example: Cart Discount

A tighter loop on a slightly more involved problem ties the techniques together. The exercise: build a cart_total function that supports a percentage discount with edge cases.

Cycle 1: An Empty Cart

Red. Write the function.

Green. Yes, returning a literal 0 for everything is "wrong," but it passes the one test that exists.

Cycle 2: A Single Item

Red. Generalize the implementation.

Both tests pass. Green.

Cycle 3: Add a Discount Parameter

The next requirement: support a percentage discount.

Red. The function doesn't take a discount_percent parameter. Extend it.

All three tests pass. Green. The parameter defaults to 0 so the existing tests still pass without changes.

Cycle 4: Float Precision

Red. The actual value is something like 49.472999..., which doesn't match 49.47 exactly. Use pytest.approx so the test tolerates float noise. The implementation already handles the math correctly; only the assertion needed adjusting. Green.

Cycle 5: Invalid Discount

What if the discount is negative or above 100? Decide the contract: raise ValueError.

Two new tests. Both Red. Add validation.

All five tests pass. Green.

The Refactor Step

Look at the function. It's six lines. The validation is one line, the math is two. There's no duplication, no clever logic, no obvious cleanup. Refactor verdict: nothing to do.

That's fine. Not every cycle ends in a refactor. The discipline is to check, not to invent.

The full test file is just as short:

Six tests, six behaviors, one function. Every line of the function is exercised. A future change that breaks any of these properties (empty cart, single item, percentage discount, float precision, negative discount, oversized discount) surfaces the broken case within seconds.

The whole exercise takes maybe ten minutes of real time. The key is the rhythm: write a test, see it fail, write the smallest fix, see it pass, look for cleanup, move to the next. No big planning step, no speculative code, no design discussion in the abstract. The design grew out of the tests, one piece at a time.

Quiz

Test-Driven Development Quiz

10 quizzes