Test-Driven Development
Writing the test first: the red-green-refactor cycle, small steps, a worked TDD session, behavior-driven development and Gherkin, and when TDD helps and when it does not.
The basic idea of
test-driven development
(TDD) is explained on the programming page with a small
unittest example. This page goes deeper: how the cycle
works in practice with pytest, a full session on a real Kestrel
Energy problem, and the related practices BDD and ATDD. Kent Beck
described TDD in Test-Driven Development: By Example
(published November 2002); the pytest basics are on
Unit Testing with pytest.
The red-green-refactor cycle
Red-green-refactor is the three-step loop at the heart of TDD, named after the colors a test runner shows:
- Red: write one small test for behavior that does not exist yet, and run it. It must fail, and for the reason you expect.
- Green: write the simplest code that makes this test, and all earlier tests, pass. Shortcuts are allowed; even a hard-coded answer.
- Refactor: improve the code and the tests (names, duplication, structure) without changing behavior, running the tests after each change. Then start the next cycle.
Why fail first? A test you have never seen fail may not test what you think: a typo in the name so it is never collected, an assertion on the wrong variable, a fixture that hides the bug. Red proves the test can detect the missing behavior; green proves the new code supplies it. Robert C. Martin put the discipline in three "laws": write no production code except to make a failing test pass; write no more of a test than is enough to fail; write no more production code than is enough to pass that one test.
Why the small steps? When a test fails after a two-minute change, the cause is in those two minutes. After a two-day change it could be anywhere. The rhythm also gives you a working, tested state many times an hour, which you can commit and return to.
The test list
In 2023 Beck restated the method as "Canon TDD", because many people had come to think of TDD as "write all the tests first". It starts with a list, not with a test: (1) write a list of the test scenarios you want to cover; (2) turn exactly one item into a concrete, runnable test; (3) change the code to make it and all previous tests pass, adding new items to the list as you discover them; (4) optionally refactor; (5) go back to step 2 until the list is empty.
The test list is that running list of behaviors and cases still to be covered. It keeps the next step small (one item at a time), and it is where new ideas go while you are busy with something else, instead of into half-finished code. It is a working note, not a document: a comment at the top of the test file, a sticky note, a scratch file.
A worked session: quarter-hours in a day
Kestrel's meters send a reading every 15 minutes, and the cost page
must check that a day is complete. Everyone "knew" a day has 96
quarter-hours, and BUG-1042 shows the cost page still assumes it.
Kwame Asante rewrites
quarter_hours_in_day(day) test-first. His test list:
[ ] normal day (7 Oct 2026): 96 [ ] day the clocks go back (25 Oct 2026): 100 [ ] day the clocks go forward (29 Mar 2026): 92 [ ] works for any year, not only 2026
The second and third items come from a question on the boundaries: what is special about a day? In the Netherlands, on the last Sunday of October the clocks go from 03:00 back to 02:00, so that day lasts 25 hours; on the last Sunday of March it lasts 23.
Step 1 · Red: the first test
Only the test exists. Running it is not a formality: the failure shows the test is collected and fails for the expected reason (the function does not exist yet).
from datetime import date
def test_normal_day_has_96_quarter_hours():
assert quarter_hours_in_day(date(2026, 10, 7)) == 96
F [100%]
=================================== FAILURES ===================================
_____________________ test_normal_day_has_96_quarter_hours _____________________
def test_normal_day_has_96_quarter_hours():
> assert quarter_hours_in_day(date(2026, 10, 7)) == 96
^^^^^^^^^^^^^^^^^^^^
E NameError: name 'quarter_hours_in_day' is not defined
test_example.py:5: NameError
=========================== short test summary info ============================
FAILED test_example.py::test_normal_day_has_96_quarter_hours - NameError: nam...
1 failed in 0.17s
Step 2 · Green: fake it
The simplest code that passes is to return the constant. That feels like cheating, and it is meant to: the next test forces the real implementation, and in the meantime the test file, the import and the name are proven to work. Beck calls this "fake it till you make it".
def quarter_hours_in_day(day):
return 96
Step 3 · Red: the day the clocks go back
The next item on the list becomes a test:
from datetime import date
def quarter_hours_in_day(day):
return 96
def test_normal_day_has_96_quarter_hours():
assert quarter_hours_in_day(date(2026, 10, 7)) == 96
def test_day_the_clocks_go_back_has_100():
assert quarter_hours_in_day(date(2026, 10, 25)) == 100
.F [100%]
=================================== FAILURES ===================================
_____________________ test_day_the_clocks_go_back_has_100 ______________________
def test_day_the_clocks_go_back_has_100():
> assert quarter_hours_in_day(date(2026, 10, 25)) == 100
E assert 96 == 100
E + where 96 = quarter_hours_in_day(datetime.date(2026, 10, 25))
E + where datetime.date(2026, 10, 25) = date(2026, 10, 25)
test_example.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_day_the_clocks_go_back_has_100 - assert 96 == 100
1 failed, 1 passed in 0.29s
Step 4 · Still red: the obvious implementation
Now Kwame writes the real thing: take local midnight at the start of the day and of the next day, in the Europe/Amsterdam time zone, and divide the difference by 15 minutes. It looks right. The test disagrees:
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
AMSTERDAM = ZoneInfo("Europe/Amsterdam")
def quarter_hours_in_day(day):
start = datetime.combine(day, time(0), tzinfo=AMSTERDAM)
end = datetime.combine(day + timedelta(days=1), time(0), tzinfo=AMSTERDAM)
return (end - start) // timedelta(minutes=15)
def test_normal_day_has_96_quarter_hours():
assert quarter_hours_in_day(date(2026, 10, 7)) == 96
def test_day_the_clocks_go_back_has_100():
assert quarter_hours_in_day(date(2026, 10, 25)) == 100
.F [100%]
=================================== FAILURES ===================================
_____________________ test_day_the_clocks_go_back_has_100 ______________________
def test_day_the_clocks_go_back_has_100():
> assert quarter_hours_in_day(date(2026, 10, 25)) == 100
E assert 96 == 100
E + where 96 = quarter_hours_in_day(datetime.date(2026, 10, 25))
E + where datetime.date(2026, 10, 25) = date(2026, 10, 25)
test_example.py:18: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_day_the_clocks_go_back_has_100 - assert 96 == 100
1 failed, 1 passed in 0.32s
This is where the test earns its keep. In Python, subtracting two
datetimes that share the same tzinfo object ignores
their UTC offsets and simply compares wall-clock times: midnight to
midnight is 24 hours, even on a 25-hour day. Without the test
written first, this version would have looked finished, and BUG-1042
("Daily total shows 96 quarter-hours on 25 Oct 2026", used as the
xfail example on the
pytest page)
would have been closed as fixed while still being there.
Step 5 · Green: convert to UTC first
Converting both moments to UTC before subtracting gives the real elapsed time. Kwame also adds the third item from the list:
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
AMSTERDAM = ZoneInfo("Europe/Amsterdam")
def quarter_hours_in_day(day):
start = datetime.combine(day, time(0), tzinfo=AMSTERDAM)
end = datetime.combine(day + timedelta(days=1), time(0), tzinfo=AMSTERDAM)
elapsed = end.astimezone(timezone.utc) - start.astimezone(timezone.utc)
return elapsed // timedelta(minutes=15)
def test_normal_day_has_96_quarter_hours():
assert quarter_hours_in_day(date(2026, 10, 7)) == 96
def test_day_the_clocks_go_back_has_100():
assert quarter_hours_in_day(date(2026, 10, 25)) == 100
def test_day_the_clocks_go_forward_has_92():
assert quarter_hours_in_day(date(2026, 3, 29)) == 92
... [100%] 3 passed in 0.21s
The 92 test passed the first time it ran, which breaks the "see it fail" rule. That is acceptable when the implementation already covers the case, but check that the test can fail: change the expected 92 to 96 for a moment, see red, and change it back. The last item on the list ("any year") is covered by the design, since the time zone database knows the rules for every year, and can be checked for many dates at once with property-based testing.
Step 6 · Refactor
Everything is green, so now the code and the tests can be tidied without changing behavior: a named constant for the quarter-hour, a small helper for local midnight, a docstring, and the three similar tests merged into one parametrized test with readable ids. The tests run after every small change; they stay green:
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
import pytest
AMSTERDAM = ZoneInfo("Europe/Amsterdam")
QUARTER_HOUR = timedelta(minutes=15)
def local_midnight(day):
return datetime.combine(day, time(0), tzinfo=AMSTERDAM)
def quarter_hours_in_day(day):
"""Number of 15-minute readings a meter sends on this day (Amsterdam time)."""
start = local_midnight(day).astimezone(timezone.utc)
end = local_midnight(day + timedelta(days=1)).astimezone(timezone.utc)
return (end - start) // QUARTER_HOUR
@pytest.mark.parametrize(
"day, expected",
[
pytest.param(date(2026, 10, 7), 96, id="normal day"),
pytest.param(date(2026, 10, 25), 100, id="clocks go back"),
pytest.param(date(2026, 3, 29), 92, id="clocks go forward"),
],
)
def test_quarter_hours_in_day(day, expected):
assert quarter_hours_in_day(day) == expected
============================= test session starts ============================== collecting ... collected 3 items test_example.py::test_quarter_hours_in_day[normal day] PASSED [ 33%] test_example.py::test_quarter_hours_in_day[clocks go back] PASSED [ 66%] test_example.py::test_quarter_hours_in_day[clocks go forward] PASSED [100%] ============================== 3 passed in 0.16s ===============================
Refactoring is only safe because the tests exist; that is TDD's second payoff, after the bug it caught. The whole session took Kwame about twenty minutes and left four things behind: working code, a test for every case on the list, a clear record of the DST rule, and a fix for BUG-1042 well before 25 October.
Fixing bugs test-first
Even teams that do not practice TDD for new code often use it for bugs. The order for BUG-1041 (the 23:00 night price, see Test Cases and Bug Reports):
- Write a test that reproduces the bug: a reading at 23:15 must cost €0.34. Run it and watch it fail with €0.47. Now the bug is captured in code, not only in a ticket.
-
Fix the code (
> 23becomes>= 23) and watch the test pass, together with all the others. - Keep the test. It is now a regression test that fails if anyone ever reintroduces the defect.
A fix without a failing test first proves only that the new code passes; it does not prove the test would have caught the old code. The failure report from step 1 is shown on the pytest page.
When TDD helps and when it does not
TDD pays off most for:
- Logic with clear rules: prices, rounding, date and time rules, validation, parsing. The cases can be listed in advance, and the edge cases are where the bugs live.
- Bug fixes, as above.
- Code that will change often, because the tests make later refactoring safe.
- Designing an interface: writing the call before the function shows whether it is pleasant to use. Code that is hard to test first is usually hard to use, and the fix is often a dependency passed in (see Test Doubles).
It helps little, or needs adapting, for:
- Exploring an unknown library or API. Write a throwaway experiment (a spike) to learn how it behaves, then delete it and build the real code test-first.
- Visual layout and user experience, where the expected result is "looks right and feels right" and a human has to look.
- Thin glue code that only passes data between well-tested parts; an integration test covers it better.
Research on TDD's effect on defects and speed gives mixed results; treat strong claims either way with care. And TDD is a way to develop, not a test strategy. It produces fast unit tests, but no integration, performance, security, accessibility or exploratory testing; those still need their own place in the strategy.
Behavior-driven development
Behavior-driven development (BDD) grew out of TDD. Dan North introduced it in the article "Introducing BDD" (2006), because people kept asking where to start and what to call a test. His answer: talk about behavior instead of tests, name tests as sentences about what the system should do, and describe that behavior with concrete examples that the business, developers and testers write together. The examples become automated tests, so the specification and the tests cannot drift apart.
Given, When, Then
Given-When-Then is the structure of a BDD scenario: Given describes the starting situation, When the event or action, and Then the expected outcome. It is the arrange-act-assert of a test in business language, and many teams use it for acceptance criteria on user stories even without any BDD tool. For the cost page story PBI-450:
Given a customer with a day_night contract When a reading of 1.5 kWh arrives for 23:15 Then the cost shown for 23:15 is EUR 0.34
Isabel Santos can check that scenario against the tariff sheet without reading code, and a developer can turn it into a test without guessing. The number is part of the example on purpose: "the night price is used" leaves room for interpretation; "€0.34" does not.
Gherkin
Gherkin is the plain-text language in
which BDD scenarios are written so that tools can run them. A
.feature file uses a small set of keywords:
Feature, Rule,
Background (steps shared by every scenario),
Scenario (also Example),
Given, When, Then,
And, But, and
Scenario Outline with an Examples table to
run one scenario for several rows of data. Lines starting with
# are comments and @ marks a tag:
@pbi-450
Feature: Costs this month
Customers on a day_night contract see each reading priced
at the rate that applied when it was measured.
Background:
Given a customer with a day_night contract
And the day price is EUR 0.3120 and the night price is EUR 0.2240 per kWh
Scenario: A late-evening reading is charged at the night price
When a reading of 1.5 kWh arrives for 23:15
Then the cost shown for 23:15 is EUR 0.34
Scenario Outline: Readings around the tariff boundaries
When a reading of 1.5 kWh arrives for <time>
Then the cost shown for <time> is EUR <cost>
Examples:
| time | cost |
| 06:45 | 0.34 |
| 07:00 | 0.47 |
| 22:45 | 0.47 |
| 23:00 | 0.34 |
Write scenarios about what the customer sees and the business rule, not about clicks: "When a reading of 1.5 kWh arrives" stays true if the page is redesigned; "When I click the third tab and scroll down" does not.
Running scenarios: Cucumber and pytest
Cucumber is the best-known tool that
runs Gherkin: it reads the .feature files and matches
each step to a step definition, a small function in code
that performs the step. Gherkin itself is maintained by the Cucumber
project, and Cucumber exists for many languages; in Python, the
plugin pytest-bdd and the tool behave do the same job. They are
worth it when business people really read and write the feature
files. When only developers read them, the extra layer of step
definitions adds work without adding understanding, and the same
scenarios can simply be pytest tests that keep the Given, When and
Then as structure:
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
import pytest
PRICES = {
("day_night", "day"): Decimal("0.3120"),
("day_night", "night"): Decimal("0.2240"),
}
def cost_shown(contract, kwh, at):
"""What the cost page shows for one reading."""
rate = "night" if at.hour < 7 or at.hour >= 23 else "day"
price = PRICES[contract, rate]
return (Decimal(str(kwh)) * price).quantize(Decimal("0.01"), ROUND_HALF_UP)
@pytest.mark.parametrize(
"at, cost",
[("06:45", "0.34"), ("07:00", "0.47"), ("22:45", "0.47"), ("23:00", "0.34")],
ids=["06:45", "07:00", "22:45", "23:00"],
)
def test_readings_around_the_tariff_boundaries(at, cost):
# Given a customer with a day_night contract
contract = "day_night"
# When a reading of 1.5 kWh arrives for <time>
hour, minute = map(int, at.split(":"))
shown = cost_shown(contract, 1.5, datetime(2026, 10, 5, hour, minute))
# Then the cost shown for <time> is EUR <cost>
assert shown == Decimal(cost)
============================= test session starts ============================== collecting ... collected 4 items test_example.py::test_readings_around_the_tariff_boundaries[06:45] PASSED [ 25%] test_example.py::test_readings_around_the_tariff_boundaries[07:00] PASSED [ 50%] test_example.py::test_readings_around_the_tariff_boundaries[22:45] PASSED [ 75%] test_example.py::test_readings_around_the_tariff_boundaries[23:00] PASSED [100%] ============================== 4 passed in 0.19s ===============================
Acceptance test-driven development
Acceptance test-driven development (ATDD) applies the test-first idea one level up: before development of a story starts, the team writes the acceptance tests together with the business, and the story meets its acceptance criteria when they pass (it is Done when it also meets the Definition of Done). TDD drives the design of units from the developer's side; ATDD drives the whole feature from the user's side. In practice the two nest: a failing acceptance test for PBI-450 stays red for a few days while many small TDD cycles build the parts underneath.
ATDD, BDD and specification by example are close relatives, and teams use the names loosely. What they share matters more than the label: concrete examples agreed before coding, usually in a short conversation between business, development and testing (the three amigos), turned into automated checks. How that conversation fits into the sprint is described on Quality in Agile Teams.
Common mistakes
- Writing all the tests first. TDD is one test at a time. Twenty failing tests at once give no direction and hide which change fixed what.
- Skipping the red step, or not reading why the test failed. A test that fails with an import error is not yet testing the behavior.
- Skipping refactor. The code passes, so it stays. After a few weeks the code is a pile of "simplest things that could work", and the tests are as duplicated as the code.
- Testing implementation details: private helpers, the order of internal calls, exact SQL. Such tests break during the very refactoring TDD is meant to make safe.
- Steps that are too big. If you have been red for half an hour, the step was too big: go back to green (revert), and take a smaller one.
- Gherkin as a UI script, written by developers alone: dozens of "When I click" lines that no business person reads and every redesign breaks.