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:

  1. Red: write one small test for behavior that does not exist yet, and run it. It must fail, and for the reason you expect.
  2. Green: write the simplest code that makes this test, and all earlier tests, pass. Shortcuts are allowed; even a hard-coded answer.
  3. Refactor: improve the code and the tests (names, duplication, structure) without changing behavior, running the tests after each change. Then start the next cycle.
The red-green-refactor cycle. Red: write one failing test. Run it and see it fail, then Green: make it pass in the simplest way. When all tests pass, Refactor: clean up while staying green. Then take the next test from the test list and go back to Red. Red write one failing test Green make it pass, simply Refactor clean up, stay green it fails as expected all tests pass next item on the list
One cycle takes minutes, not hours. The refactor step is the one most often skipped.

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):

  1. 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.
  2. Fix the code (> 23 becomes >= 23) and watch the test pass, together with all the others.
  3. 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.