Test-driven development is usually summarised in three words: red, green, refactor. Write a test that fails, make it pass, clean up. Most candidates can recite that. Fewer can explain why the order matters or what goes wrong when people skip a step, and that’s what an interviewer is listening for.
Rather than describe the cycle in the abstract, this article walks through a real session. Everything below was run with Python 3.14’s unittest, and the outputs are what the terminal showed.
The problem: splitting a bill in cents
A restaurant app lets a group split the bill evenly. Money is stored in integer cents, and the shares must add up exactly to the total; no cent may vanish or appear from nowhere. That second requirement is what makes this more than a division.
Cycle one: the even split
The first test is the simplest real case:
import unittest
from bill import split_cents
class SplitCents(unittest.TestCase):
def test_even_split(self):
self.assertEqual(split_cents(1000, 2), [500, 500])There’s no bill.py yet, so running it gives:
ImportError: Failed to import test module: test_split
ModuleNotFoundError: No module named 'bill'This is red, but not the useful kind. The test hasn’t run at all, so the failure says nothing about the behaviour. A test that has never failed for the right reason might be unable to fail: it could be asserting on the wrong thing, or not running at all. So the first move is a stub that lets the test execute:
def split_cents(total, people):
passFAIL: test_even_split (test_split.SplitCents.test_even_split)
AssertionError: None != [500, 500]That’s the red you want: the test ran, called the function, and failed on the assertion. Now green, with the simplest code that passes:
def split_cents(total, people):
return [total // people] * peopleRan 1 test in 0.000s
OKIt’s tempting to write the “real” implementation straight away, since you can already see that 1000 / 3 will be a problem. The discipline of TDD says don’t. If you write code the tests don’t require, that code isn’t tested, and the next test you write may pass immediately, which tells you nothing.
Cycle two: leftover cents
Now the case that forces the design. The rule, agreed with the product owner: leftover cents go to the first people in the list.
def test_leftover_cents_go_to_the_first_people(self):
self.assertEqual(split_cents(1000, 3), [334, 333, 333])FAIL: test_leftover_cents_go_to_the_first_people (test_split.SplitCents.test_leftover_cents_go_to_the_first_people)
AssertionError: Lists differ: [333, 333, 333] != [334, 333, 333]
First differing element 0:
- [333, 333, 333]
+ [334, 333, 333]The failure is precise: the shares sum to 999, and the missing cent should be on the first person. Make it pass with something straightforward:
def split_cents(total, people):
shares = [total // people] * people
for i in range(total - sum(shares)):
shares[i] += 1
return sharesBoth tests pass. The code works, but it’s clumsy: it computes the shares, sums them to discover the remainder it could have computed directly, then patches the list.
Refactor under green
Now, and only now, improve the structure. Every test is green, so any change that turns something red is a mistake in the refactoring, and you know exactly which edit caused it.
def split_cents(total_cents, people):
"""Split an amount into near-equal integer shares that add up exactly."""
base, leftover = divmod(total_cents, people)
return [base + 1 if i < leftover else base for i in range(people)]Ran 2 tests in 0.000s
OKThe parameter got a clearer name, divmod states the intent (a base share and a remainder), and the list is built in one pass. Behaviour didn’t change, and the tests prove it.
The rule that matters here: refactoring never changes behaviour. If, while tidying, you notice that negative totals or zero people aren’t handled, don’t fix it in the same edit. Write it down, finish the refactor, and start a new cycle with a failing test. Mixing the two is how people end up with a red test and no idea whether the refactor or the new feature broke it.
The refactor step also applies to tests. If three tests build the same fixture, extracting a helper is a refactor too, done while green.
Cycle three: nobody to split between
The note from the refactor becomes the next test:
def test_nobody_to_split_between(self):
with self.assertRaisesRegex(ValueError, "at least one person"):
split_cents(1000, 0)ERROR: test_nobody_to_split_between (test_split.SplitCents.test_nobody_to_split_between)
ZeroDivisionError: division by zerounittest reports this as an ERROR, not a FAIL: the code raised an exception the test didn’t expect. That still counts as the right red, because it shows the current behaviour (a crash with an unhelpful message) and the test is clearly checking for something else. The fix is a guard clause:
def split_cents(total_cents, people):
"""Split an amount into near-equal integer shares that add up exactly."""
if people < 1:
raise ValueError(f"need at least one person, got {people}")
base, leftover = divmod(total_cents, people)
return [base + 1 if i < leftover else base for i in range(people)]test_even_split (test_split.SplitCents.test_even_split) ... ok
test_leftover_cents_go_to_the_first_people (test_split.SplitCents.test_leftover_cents_go_to_the_first_people) ... ok
test_nobody_to_split_between (test_split.SplitCents.test_nobody_to_split_between) ... ok
Ran 3 tests in 0.000s
OKThree short cycles, each a minute or two. The result is a function where every behaviour exists because a test demanded it, and each of those tests has been seen failing.
What the order buys you, and common misreadings
Red first proves the test can fail. A test written after the code often passes on its first run, and you can’t tell whether that’s because the code is right or because the test is broken (wrong function imported, assertion on the wrong variable, test method missing the test_ prefix so it never runs). Watching it fail first rules that out.
Minimal green keeps tests in charge of the design. Writing just enough code means the next test has to describe the next behaviour. Over time, the tests become an accurate specification, and there’s very little untested code.
Refactor is the step people skip. Under deadline pressure, teams go red, green, red, green and never tidy up, then conclude TDD produces messy code. The cycle explicitly budgets time for cleanup while the safety net is fresh.
A few misreadings come up in interviews:
- “TDD means writing all the tests first.” No: one test at a time, each followed by code. Writing twenty tests up front gives you twenty red tests and no feedback loop.
- “TDD guarantees good design.” It gives frequent feedback; you still have to act on it. A test that needs ten mocks to set up is telling you the design has too many dependencies.
- “Every line must be test-driven.” Many experienced practitioners use TDD for logic with clear rules and test after for exploratory UI work or spikes. Being able to explain when you’d skip it is a stronger answer than claiming you never do.
Practice question
Finance asks for refunds to use the same split: split_cents(-1000, 3). Without writing the code first, what does the current function return, is that acceptable, and how would you proceed using red, green, refactor?
Answer
Python’s divmod(-1000, 3) floors toward negative infinity, giving a base of -334 and a remainder of 2. So the first two people get -333 and the third gets -334: running it gives [-333, -333, -334], which sums to -1000. The total is correct, but the extra cent now lands on the last person, the opposite of the rule for payments. Whether that’s acceptable is a product decision, so the first step is to ask: should the first person also absorb the extra cent of a refund, so payment and refund mirror each other?
Assuming yes, I’d write the test first: assertEqual(split_cents(-1000, 3), [-334, -333, -333]), run it, and see it fail with the list difference. That failure documents the current behaviour, which is useful in itself. For green, the simplest change is to split the absolute value and reapply the sign: compute the shares for abs(total_cents) and negate them when the total is negative. Then refactor if needed, for example extracting the sign handling into a clearly named line, with all four tests green. I’d also add a test that the shares always sum to the total for a handful of positive and negative values, or a property-based test, since “no cent appears or vanishes” is the invariant everyone actually cares about.