Programming with PythonTesting and Code Quality › Day 73

Hands-on lab — Day 73: Test-Driven Development

Commands

Setup

cd labs/sections/programming-with-python/day-073-test-driven-development
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version

Run

cat starter/cycles.md
.venv/bin/pytest starter/test_bowling.py
.venv/bin/pytest examples/test_bowling.py
.venv/bin/pytest examples/test_bowling.py -q --collect-only
cat examples/cycles/cycle-1-red.txt
cat examples/cycles/cycle-7-red.txt
cat examples/cycles/README.md

Test

bash tests/run_tests.sh

File tree

examples/bowling.py
examples/cycles/cycle-1-green.txt
examples/cycles/cycle-1-red.txt
examples/cycles/cycle-2-green.txt
examples/cycles/cycle-2-red.txt
examples/cycles/cycle-3-green.txt
examples/cycles/cycle-3-red.txt
examples/cycles/cycle-4-green.txt
examples/cycles/cycle-4-red.txt
examples/cycles/cycle-4-refactor.txt
examples/cycles/cycle-5-green.txt
examples/cycles/cycle-5-red.txt
examples/cycles/cycle-6-green.txt
examples/cycles/cycle-6-red.txt
examples/cycles/cycle-7-green.txt
examples/cycles/cycle-7-red.txt
examples/cycles/cycle-8-mutant.txt
examples/cycles/cycle-8-passed-immediately.txt
examples/cycles/README.md
examples/test_bowling.py
expected-output/FIELDS.md
expected-output/suite-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/bowling.py
starter/cycles.md
starter/test_bowling.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 073 lab — The bowling kata, one failing test at a time

Lesson

  • Lesson title: Test-Driven Development
  • Day number: 73 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-073-test-driven-development
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-073-test-driven-development when the site is running.

Purpose

Day 73 is about the order in which you write things, and there is only one way to learn it: perform the loop yourself, eight times, and keep the evidence.

This lab hands you an empty module. starter/bowling.py contains a docstring and nothing else — no function signature, no stub, nothing to fill in. That is deliberate. In test-driven development the implementation does not exist until a failing test asks for it, so the file you are meant to grow starts genuinely empty rather than pretending to.

starter/cycles.md is the kata sheet. It gives you eight cycles in order, each with the exact test function to add, a blank RED block, a blank GREEN block, and one question to answer in a sentence. Your job for each cycle is the whole discipline in five moves: add one test, run the suite, watch it fail, paste the failure, write the least code that passes, run again, paste the pass.

Cycle 8 is the point of the day. Two tests — a perfect game and a real 133-point scorecard — are added together and both pass on the first run. That is not a victory; it is an unanswered question, because a test that has never been seen to fail has never been shown to be connected to anything. You then break the implementation on purpose in a throwaway copy and watch both tests go red, which is the only thing that makes them worth keeping.

The test runner takes the same view of your work that the lesson takes of yours: it does not trust a stored result. It re-runs the reference suite from source, breaks the reference implementation four different ways to prove the suite can actually fail, and reads all seventeen recorded captures in examples/cycles/ to check that their pass counts chain the way a genuine red-green sequence would.

Learning objectives

  • Perform eight red-green-refactor cycles end to end, adding exactly one test per cycle and never two.
  • Read a pytest failure well enough to say whether it failed for the reason you predicted, and distinguish that from a boring failure such as a typo or a wrong import.
  • Recognise five distinct kinds of red — a missing function, a wrong number, a missing exception class, a refusal that did not happen, and a crash that should have been a refusal — and say what each one tells you.
  • Use fake it till you make it at cycle 1 and triangulation at cycle 2 as deliberate techniques, and watch the second remove the first.
  • Perform a refactor and prove it was one, by showing the identical passing count on either side of it.
  • Specify a refusal from the caller's side: decide in a test that bad input raises a module-specific ScoringError, before any implementation exists to influence the decision.
  • Test a test that has never been red, by mutating the code it covers and confirming it goes red.
  • Judge your own recorded history against the same standard the runner applies to the reference: one failure per red, the earlier cycles still green, and a refactor that moves no count.

Prerequisites

  • The Day 73 lesson (read it first — it walks the same kata with the real captures, and this lab is where you reproduce them yourself).
  • Day 72: fixtures, parametrization, and designing a test with one clear reason to fail.
  • Day 71: pytest discovery and naming, plain assert, pytest.raises, and the always-passing-test anti-pattern this lab is the answer to.
  • Day 66: raising exceptions on purpose and defining your own exception class.
  • Day 43: creating a virtual environment with python3 -m venv and installing a pinned dependency into it.
  • A text editor and a terminal. Nothing beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0, pytest 9.1.1, bash 3.2.57).
  • Linux — fully supported (any distribution with Python 3, pytest and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3 and .venv\Scripts\pytest.exe for .venv/bin/pytest. The test runner is a bash script, so it needs WSL, Git Bash, or another bash. Nothing about the kata itself is platform-specific.

Hardware requirements

Any computer that runs Python 3. The whole lab is two small Python files, a kata sheet, and seventeen short text captures; the reference suite finishes in about a hundredth of a second. No special memory, disk, or GPU.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • pytest — one pinned third-party package, pytest==9.1.1. See requirements/README.md.
  • bash, sed and cmp for the test runner (preinstalled on macOS and Linux).

The implementation under test imports nothing at all. That is not an accident: a pure function over a list of integers is the easiest thing in the world to drive test-first, which is exactly why this kata was chosen to teach the technique. Day 74 takes on the harder case, where the code you are testing talks to something outside itself.

Free and open-source options

Everything here is free and open source: Python, bash, and pytest (MIT licence). No account, API key, or purchase is needed at any point. The lesson's Alternatives section names pytest-bdd, behave, Hypothesis and mypy as neighbouring approaches — all of them free and open source too, all installable with pip, and none of them required to complete this lab.

Installation

One third-party package, installed once into a lab-local virtual environment:

cd labs/sections/programming-with-python/day-073-test-driven-development
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version

That pip install is the only moment this lab touches the network. Afterwards the kata, the suite and the test runner all run fully offline. .venv/ is ignored by version control; never commit it.

If you would rather not create a lab-local environment, point the runner at a pytest you already have: PYTEST=/path/to/pytest bash tests/run_tests.sh.

File structure

day-073-test-driven-development/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── cycles.md                   ← THE KATA SHEET: eight cycles, blank RED and GREEN blocks
│   ├── bowling.py                  ← YOUR implementation — deliberately empty
│   └── test_bowling.py             ← YOUR suite — imports only, one test per cycle
├── examples/
│   ├── bowling.py                  ← reference implementation (do not read until you have finished)
│   ├── test_bowling.py             ← reference suite: the nine tests, in cycle order
│   └── cycles/
│       ├── README.md               ← what each cycle added, and the honest limits of a recorded history
│       ├── cycle-1-red.txt … cycle-7-green.txt   ← fourteen real captures, one per red and green
│       ├── cycle-4-refactor.txt    ← the refactor that moved no count
│       ├── cycle-8-passed-immediately.txt        ← nine passed, no red at all
│       └── cycle-8-mutant.txt      ← the same nine tests against a deliberately broken copy
├── tests/
│   └── run_tests.sh                ← 40 checks; exits 0 only if all pass
├── expected-output/
│   ├── suite-run.txt               ← real captured run of the reference suite, plus its collected ids
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required behaviour of your score(), and of your recorded history
├── requirements/
│   ├── requirements.txt            ← pytest==9.1.1
│   └── README.md                   ← what the dependency is for, and the one-time install
├── troubleshooting.md
└── security.md

How to run

From this directory, after the install above:

## 1. Read the kata sheet. This is your only specification.
cat starter/cycles.md

## 2. Cycle 1: add ONE test to starter/test_bowling.py, then run this BEFORE
##    writing any implementation. It must fail. Paste the failure into cycles.md.
.venv/bin/pytest starter/test_bowling.py

## 3. Write the least code in starter/bowling.py that passes, and run again.
##    Paste that run into the GREEN block. Repeat for cycles 2 through 8.
.venv/bin/pytest starter/test_bowling.py

## 4. Only after you have finished all eight cycles: the reference suite.
.venv/bin/pytest examples/test_bowling.py
.venv/bin/pytest examples/test_bowling.py -q --collect-only

## 5. The recorded history of someone else performing the same kata.
cat examples/cycles/cycle-1-red.txt
cat examples/cycles/cycle-7-red.txt
cat examples/cycles/README.md

## 6. Check your work.
bash tests/run_tests.sh

What the commands do

  • cat starter/cycles.md — prints the kata sheet: the rules of bowling you need and no more, how to run a cycle, and the eight cycles in order with the exact test function for each and blank blocks for your captures.
  • .venv/bin/pytest starter/test_bowling.py — runs your suite against your module. Naming the file matters: a bare .venv/bin/pytest would try to collect starter/test_bowling.py and examples/test_bowling.py, two same-named modules with no package around them, and refuse with an import file mismatch.
  • .venv/bin/pytest examples/test_bowling.py — runs the reference suite: nine tests, all passing, in about 0.01 seconds. This is what your finished work should be equivalent to.
  • .venv/bin/pytest examples/test_bowling.py -q --collect-only — lists the nine test ids without running them, so you can see the cycle order preserved in the file: gutter game, all ones, strike, spare, then the three refusals, then the perfect game and the real game.
  • cat examples/cycles/cycle-1-red.txt — the first red of the whole kata: AttributeError: module 'bowling' has no attribute 'score'. Compare it with your own cycle 1 red; they should say the same thing.
  • cat examples/cycles/cycle-7-red.txt — the most instructive capture in the set. The short game does not fail politely, it crashes with IndexError: list index out of range from inside the loop, and pytest prints the whole function with an arrow at the offending line.
  • cat examples/cycles/README.md — what each cycle actually added, plus an honest section on why a recorded history is easy to fake and what the runner can and cannot prove about one.
  • bash tests/run_tests.sh — 40 checks while the starter is untouched, 41 once you have completed the kata. Exits 0 only if all of them pass.

Expected output

The reference suite, captured on the authoring machine — the full session is in expected-output/suite-run.txt:

$ .venv/bin/pytest examples/test_bowling.py
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: <repo>/labs/sections/programming-with-python/day-073-test-driven-development/examples
plugins: cov-7.1.0, anyio-4.14.2
collected 9 items

test_bowling.py .........                                                [100%]

============================== 9 passed in 0.01s ===============================

Your own cycle 1, before any implementation exists, should read like examples/cycles/cycle-1-red.txt:

    def test_a_gutter_game_scores_zero():
>       assert bowling.score([0] * 20) == 0
               ^^^^^^^^^^^^^
E       AttributeError: module 'bowling' has no attribute 'score'

test_bowling.py:9: AttributeError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_gutter_game_scores_zero - AttributeError: modu...
============================== 1 failed in 0.01s ===============================

Nothing in this lab reads the clock, the network, or a random number, so every count above is identical on any machine with Python 3 and pytest 9. Only the timings, the rootdir line, the plugin list and the memory address printed in an assertion diff vary. expected-output/FIELDS.md lists exactly what your finished score(rolls) must do for all eleven inputs the reference suite uses, and exactly which summary line each of your eight cycles must show.

Validation steps

  1. .venv/bin/pytest examples/test_bowling.py reports 9 passed and exits 0.
  2. Every cycle in starter/cycles.md has a RED block containing a real failure and a GREEN block containing a real pass.
  3. Each RED block shows exactly one failing test. Two means you wrote two tests in one cycle, which is the one rule of this lab.
  4. Each RED block from cycle 2 onward shows the earlier cycles still green — cycle 5's red must read 1 failed, 4 passed.
  5. Each GREEN block for cycle N reads exactly N passed.
  6. Cycle 4 has three blocks: red, green, and a refactor run showing the same 4 passed as its green. A refactor that moves the count is not a refactor.
  7. Cycle 8 has a run showing 9 passed and a run against a deliberately broken copy in which both new tests fail.
  8. You answered all four questions at the bottom of cycles.md in your own words, before opening examples/cycles/README.md.
  9. bash tests/run_tests.sh reports 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

Expected final line while the starter is untouched:

40 checks, 0 failure(s).

A full captured run is in expected-output/test-run.txt. The 40 checks fall into four groups, and the third belongs to this day and no other:

  1. The reference suite passes — pytest exits 0 with nine tests, and six named test ids are actually collected.
  2. The suite has teeth — the reference implementation is copied to a directory made with mktemp -d, broken by a single sed substitution, and pytest must exit non-zero. Four different mutations are tried: an off-by-one in the total, removing the frame-size refusal, removing the per-roll refusal, and disabling the strike branch. A test suite that cannot fail is not a test suite.
  3. The recorded history is genuine — every RED capture in examples/cycles/ must really report one failure, every GREEN capture must really report the exact number of passes that cycle should have reached, and the counts must chain. Cycle 5's red can only say 4 passed if cycles 1 to 4 really were written first and really ended green.
  4. The starter is in a sensible state — both starter files are valid Python and the kata sheet still dictates all eight cycles.

Once starter/bowling.py defines score, the runner stops checking that the module is empty and starts grading it: it runs your module against the reference suite, so a weaker set of tests of your own cannot let a weaker implementation through, and separately runs your own suite. That gives 41 checks, 0 failure(s).

Cleanup

The lab writes nothing into its own directory. To remove the environment and Python's bytecode cache:

rm -rf .venv
find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +

To reset your work, restore the starter from git: git checkout -- starter/. The test runner makes its own temporary directories with mktemp -d and removes each one as that check finishes, and sets PYTHONDONTWRITEBYTECODE=1 so its own runs leave no cache behind.

Troubleshooting

See troubleshooting.md for the full list: pytest not found and the three places the runner looks for it; the no tests ran with exit code 5 that is expected before cycle 1; ModuleNotFoundError: No module named 'bowling'; the import file mismatch you get from running bare pytest; the AttributeError that is the correct first red; Failed: DID NOT RAISE ScoringError and what it means when it appears after you wrote the check; the IndexError that is cycle 7's whole point; why from bowling import score, ScoringError turns a clean 1 failed, 4 passed into a single collection error; what to do when your RED block shows two failures or your GREEN block shows too few passes; and the macOS sed -i difference.

Security notes

See security.md. Short version: the only network moment is the pinned pip install, which lands in a lab-local .venv/ you can delete; a test suite is executable code you are inviting in, so read an unfamiliar one before you run it; writing the refusal first is a security practice, because most missing input validation is missing because nobody ever wrote the sentence "this input must be refused" anywhere; assert is stripped by Python's -O flag, which is why a production refusal belongs in an explicit raise; and the most common way a vulnerability ships is a red test that was adjusted until it passed.

Extension exercises

  1. Find a mutation the suite misses. The runner tries four mutations and the suite catches all four. Find a fifth that changes behaviour and leaves all nine tests green — a comparison operator, a boundary constant, a + 1. One exists. Then write the test that kills it, watch it go red against the mutant and green against the original, and you will have added a genuinely new specification rather than a tenth restatement of an old one.
  2. Do the kata again, from scratch, in twenty minutes. Delete your starter/ work, restore it with git checkout -- starter/, and repeat. The second performance is where the rhythm stops being a procedure you are following and starts being how you work. Note which cycle you were tempted to skip; that is the one to watch.
  3. Drive the tenth-frame rules properly. The reference treats bonus rolls with a bonus_rolls counter. Write a new failing test for a tenth-frame spare followed by a strike, work the arithmetic by hand first, and see whether the existing implementation already satisfies it. If it does, you have found an unspecified behaviour that happens to be right — which is worth exactly as much as cycle 8's two immediate greens until you mutate the code and watch your new test fail.
  4. Add a property. Install Hypothesis into your .venv and write one property the nine examples do not state: a game with no strike and no spare scores exactly the sum of its rolls. Generating only frames that total under ten is the interesting part of that exercise.
  5. Break the record on purpose. Edit one capture in examples/cycles/ so its pass count no longer chains — change cycle 5's red to say 3 passed — and run the suite. Watch which check catches it, then restore the file with git checkout -- examples/cycles/. Knowing exactly which check catches a forged history is worth more than being told the record is verified.
  • Previous day: Day 72 — Fixtures, Parametrization, and Test Design (labs/sections/programming-with-python/day-072-fixtures-parametrization-and-test-design/).
  • Next day: Day 74 — Mocking and Testing Boundaries (labs/sections/programming-with-python/day-074-mocking-and-testing-boundaries/), which takes on the case this lab deliberately avoided: code that talks to something outside itself.
  • Week 11 project: the Tested Utility Library (labs/sections/programming-with-python/projects/week-11/), where the loop practised here is applied to a module you will keep.

Expected output

FIELDS.md

# Expected output — Day 073 lab

These are real captured runs from the authoring machine (macOS 26.5.1, Apple
Silicon, Python 3.14.0, pytest 9.1.1, bash 3.2.57, 2026-07-19). Nothing in this
lab reads the clock, the network, or a random number, so the numbers below are
the same on every machine with Python 3 and pytest 9.

## Files

- `suite-run.txt` — `pytest` on the reference suite, then the same suite with
  `--collect-only -q` so you can see the nine test ids. Absolute paths appear
  as `<repo>`; on your machine that line shows your real repository path.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  untouched: 40 checks, 0 failures, exit 0. Completing the kata turns the last
  starter check into two graded checks, giving 41.
- The seventeen recorded cycle captures live in `../examples/cycles/`, not
  here, because they are teaching material rather than a description of the
  finished state. `../examples/cycles/README.md` indexes them.

## What varies between machines, and what does not

| Line | Varies? | Why |
| --- | --- | --- |
| `platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0` | yes | your platform, Python and pluggy versions; `pytest-9.1.1` is pinned by `requirements/requirements.txt` |
| `rootdir: ...` | yes | pytest reports the directory it started from |
| `plugins: cov-7.1.0, anyio-4.14.2` | yes | whatever plugins your environment has; the lab needs none of them |
| `collected 9 items` | no | the reference suite has exactly nine tests |
| `9 passed in 0.01s` | the time varies | the count does not |
| `<function score at 0x1072...>` in a failure | yes | a memory address, printed by pytest's assertion rewriting; the numbers on either side of `==` do not vary |
| every `assert N == M` pair in the cycle captures | no | they are computed from fixed lists of integers |

## Required behaviour of your `score(rolls)`

Your finished `starter/bowling.py` must satisfy exactly this. The test runner
grades it by running the *reference* suite against your module, so a weaker set
of tests cannot let a weaker implementation through.

| Call | Result | Arithmetic |
| --- | --- | --- |
| `score([0] * 20)` | `0` | ten open frames of nothing |
| `score([1] * 20)` | `20` | ten open frames of two |
| `score([10, 3, 4] + [0] * 16)` | `24` | frame 1 is 10 + 3 + 4 = 17, frame 2 is 3 + 4 = 7 |
| `score([5, 5, 3] + [0] * 17)` | `16` | frame 1 is 10 + 3 = 13, frame 2 is 3 + 0 = 3 |
| `score([10] * 12)` | `300` | ten frames of 10 + 10 + 10 |
| `score([1,4,4,5,6,4,5,5,10,0,1,7,3,6,4,10,2,8,6])` | `133` | 5 + 9 + 15 + 20 + 11 + 1 + 16 + 20 + 20 + 16 |
| `score([11] + [0] * 19)` | raises `ScoringError` | a roll knocks down 0 to 10 pins |
| `score([-1] + [0] * 19)` | raises `ScoringError` | same rule, other end |
| `score([7, 5] + [0] * 18)` | raises `ScoringError` | twelve pins in one frame |
| `score([0] * 19)` | raises `ScoringError` | frame 10 has only one roll |
| `score([0] * 21)` | raises `ScoringError` | one roll after the game ended |

`ScoringError` must be defined in your own module and must be a subclass of
`Exception`. The reference suite refers to it as `bowling.ScoringError`.

## Required shape of your recorded history

The runner reads `examples/cycles/` and checks the reference record, not
yours — it cannot see the blocks you paste into `starter/cycles.md`. Grade
those yourself against the same standard:

| Cycle | RED summary line must read | GREEN summary line must read |
| --- | --- | --- |
| 1 | `1 failed` | `1 passed` |
| 2 | `1 failed, 1 passed` | `2 passed` |
| 3 | `1 failed, 2 passed` | `3 passed` |
| 4 | `1 failed, 3 passed` | `4 passed`, and the refactor run also `4 passed` |
| 5 | `1 failed, 4 passed` | `5 passed` |
| 6 | `1 failed, 5 passed` | `6 passed` |
| 7 | `1 failed, 6 passed` | `7 passed` |
| 8 | (there is no red — that is the lesson) | `9 passed`, then `3 failed, 6 passed` against the broken copy |

If any RED block of yours shows more than one failing test, you wrote two tests
in one cycle. If any RED block shows zero failing tests, you wrote the code
first — which is allowed in life, but not in this kata, and not without then
proving the test can fail.

suite-run.txt

$ .venv/bin/pytest examples/test_bowling.py
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: <repo>/labs/sections/programming-with-python/day-073-test-driven-development/examples
plugins: cov-7.1.0, anyio-4.14.2
collected 9 items

test_bowling.py .........                                                [100%]

============================== 9 passed in 0.01s ===============================

$ .venv/bin/pytest examples/test_bowling.py -q --collect-only
test_bowling.py::test_a_gutter_game_scores_zero
test_bowling.py::test_a_game_of_all_ones_scores_twenty
test_bowling.py::test_a_strike_adds_the_next_two_rolls
test_bowling.py::test_a_spare_adds_the_next_roll
test_bowling.py::test_a_roll_outside_zero_to_ten_is_refused
test_bowling.py::test_a_frame_of_more_than_ten_pins_is_refused
test_bowling.py::test_a_game_with_the_wrong_number_of_rolls_is_refused
test_bowling.py::test_a_perfect_game_scores_three_hundred
test_bowling.py::test_a_real_game_scores_133

9 tests collected in 0.00s

test-run.txt

Using pytest: pytest 9.1.1
Testing the reference suite ...
  ok: examples/test_bowling.py: 9 tests, all passing
  ok: the suite collects test_a_gutter_game_scores_zero
  ok: the suite collects test_a_strike_adds_the_next_two_rolls
  ok: the suite collects test_a_spare_adds_the_next_roll
  ok: the suite collects test_a_roll_outside_zero_to_ten_is_refused
  ok: the suite collects test_a_game_with_the_wrong_number_of_rolls_is_refused
  ok: the suite collects test_a_perfect_game_scores_three_hundred
Testing that the suite fails on a broken implementation ...
  ok: an off-by-one in the total is caught
  ok: removing the frame-size refusal is caught
  ok: removing the per-roll refusal is caught
  ok: treating every strike as an open frame is caught
Testing that examples/cycles/ is a genuine red-green record ...
  ok: cycle 1 RED: 1 failed, 0 passed
  ok: cycle 1 green: 1 passed, 0 failed
  ok: cycle 2 RED: 1 failed, 1 passed
  ok: cycle 2 green: 2 passed, 0 failed
  ok: cycle 3 RED: 1 failed, 2 passed
  ok: cycle 3 green: 3 passed, 0 failed
  ok: cycle 4 RED: 1 failed, 3 passed
  ok: cycle 4 green: 4 passed, 0 failed
  ok: cycle 5 RED: 1 failed, 4 passed
  ok: cycle 5 green: 5 passed, 0 failed
  ok: cycle 6 RED: 1 failed, 5 passed
  ok: cycle 6 green: 6 passed, 0 failed
  ok: cycle 7 RED: 1 failed, 6 passed
  ok: cycle 7 green: 7 passed, 0 failed
  ok: cycle 4 refactor: 4 passed, 0 failed
  ok: cycle 8 passed-immediately: 9 passed, 0 failed
  ok: cycle 8 mutant: both tests that passed immediately were shown to fail
  ok: every capture records the pytest version that produced it
Testing starter/ ...
  ok: bowling.py is valid Python
  ok: test_bowling.py is valid Python
  ok: cycles.md dictates Cycle 1
  ok: cycles.md dictates Cycle 2
  ok: cycles.md dictates Cycle 3
  ok: cycles.md dictates Cycle 4
  ok: cycles.md dictates Cycle 5
  ok: cycles.md dictates Cycle 6
  ok: cycles.md dictates Cycle 7
  ok: cycles.md dictates Cycle 8
Note: starter/bowling.py is still empty — the kata has not been started.
  ok: starter/bowling.py is the empty module the kata begins from

40 checks, 0 failure(s).

Source files

examples/bowling.py (1823 bytes)
"""Bowling scoring."""


class ScoringError(Exception):
    """A list of rolls that cannot be scored, because it breaks a rule of bowling."""


def score(rolls):
    """Total score for a completed ten-frame game, given every roll in order."""
    _check_pins(rolls)
    total = 0
    roll = 0
    bonus_rolls = 0
    for frame in range(1, 11):
        if roll >= len(rolls):
            raise ScoringError(f"a game has ten frames; the rolls stop before frame {frame}")
        if _is_strike(rolls, roll):
            if roll + 2 >= len(rolls):
                raise ScoringError(f"the strike in frame {frame} has no two bonus rolls after it")
            total += 10 + rolls[roll + 1] + rolls[roll + 2]
            bonus_rolls = 2
            roll += 1
        else:
            if roll + 1 >= len(rolls):
                raise ScoringError(f"frame {frame} has only one roll")
            pins = rolls[roll] + rolls[roll + 1]
            if pins > 10:
                raise ScoringError(f"a frame knocks down at most 10 pins, frame {frame} has {pins}")
            if pins == 10:
                if roll + 2 >= len(rolls):
                    raise ScoringError(f"the spare in frame {frame} has no bonus roll after it")
                total += 10 + rolls[roll + 2]
                bonus_rolls = 1
            else:
                total += pins
                bonus_rolls = 0
            roll += 2
    if roll + bonus_rolls != len(rolls):
        extra = len(rolls) - roll - bonus_rolls
        raise ScoringError(f"the game ends after frame ten, but {extra} extra roll(s) follow")
    return total


def _check_pins(rolls):
    for pins in rolls:
        if pins < 0 or pins > 10:
            raise ScoringError(f"a roll knocks down 0 to 10 pins, got {pins}")


def _is_strike(rolls, roll):
    return rolls[roll] == 10
examples/cycles/cycle-1-green.txt (390 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 1 item

test_bowling.py .                                                        [100%]

============================== 1 passed in 0.01s ===============================
examples/cycles/cycle-1-red.txt (931 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 1 item

test_bowling.py F                                                        [100%]

=================================== FAILURES ===================================
________________________ test_a_gutter_game_scores_zero ________________________

    def test_a_gutter_game_scores_zero():
>       assert bowling.score([0] * 20) == 0
               ^^^^^^^^^^^^^
E       AttributeError: module 'bowling' has no attribute 'score'

test_bowling.py:9: AttributeError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_gutter_game_scores_zero - AttributeError: modu...
============================== 1 failed in 0.01s ===============================
examples/cycles/cycle-2-green.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 2 items

test_bowling.py ..                                                       [100%]

============================== 2 passed in 0.01s ===============================
examples/cycles/cycle-2-red.txt (1001 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 2 items

test_bowling.py .F                                                       [100%]

=================================== FAILURES ===================================
____________________ test_a_game_of_all_ones_scores_twenty _____________________

    def test_a_game_of_all_ones_scores_twenty():
>       assert bowling.score([1] * 20) == 20
E       assert 0 == 20
E        +  where 0 = <function score at 0x107299c70>(([1] * 20))
E        +    where <function score at 0x107299c70> = bowling.score

test_bowling.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_game_of_all_ones_scores_twenty - assert 0 == 20
========================= 1 failed, 1 passed in 0.01s ==========================
examples/cycles/cycle-3-green.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 3 items

test_bowling.py ...                                                      [100%]

============================== 3 passed in 0.01s ===============================
examples/cycles/cycle-3-red.txt (1032 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 3 items

test_bowling.py ..F                                                      [100%]

=================================== FAILURES ===================================
____________________ test_a_strike_adds_the_next_two_rolls _____________________

    def test_a_strike_adds_the_next_two_rolls():
>       assert bowling.score([10, 3, 4] + [0] * 16) == 24
E       assert 17 == 24
E        +  where 17 = <function score at 0x10744dd20>(([10, 3, 4] + ([0] * 16)))
E        +    where <function score at 0x10744dd20> = bowling.score

test_bowling.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_strike_adds_the_next_two_rolls - assert 17 == 24
========================= 1 failed, 2 passed in 0.01s ==========================
examples/cycles/cycle-4-green.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 4 items

test_bowling.py ....                                                     [100%]

============================== 4 passed in 0.01s ===============================
examples/cycles/cycle-4-red.txt (1018 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 4 items

test_bowling.py ...F                                                     [100%]

=================================== FAILURES ===================================
_______________________ test_a_spare_adds_the_next_roll ________________________

    def test_a_spare_adds_the_next_roll():
>       assert bowling.score([5, 5, 3] + [0] * 17) == 16
E       assert 13 == 16
E        +  where 13 = <function score at 0x107329e80>(([5, 5, 3] + ([0] * 17)))
E        +    where <function score at 0x107329e80> = bowling.score

test_bowling.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_spare_adds_the_next_roll - assert 13 == 16
========================= 1 failed, 3 passed in 0.02s ==========================
examples/cycles/cycle-4-refactor.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 4 items

test_bowling.py ....                                                     [100%]

============================== 4 passed in 0.01s ===============================
examples/cycles/cycle-5-green.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 5 items

test_bowling.py .....                                                    [100%]

============================== 5 passed in 0.01s ===============================
examples/cycles/cycle-5-red.txt (977 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 5 items

test_bowling.py ....F                                                    [100%]

=================================== FAILURES ===================================
__________________ test_a_roll_outside_zero_to_ten_is_refused __________________

    def test_a_roll_outside_zero_to_ten_is_refused():
>       with pytest.raises(bowling.ScoringError):
                           ^^^^^^^^^^^^^^^^^^^^
E       AttributeError: module 'bowling' has no attribute 'ScoringError'

test_bowling.py:25: AttributeError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_roll_outside_zero_to_ten_is_refused - Attribut...
========================= 1 failed, 4 passed in 0.02s ==========================
examples/cycles/cycle-6-green.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 6 items

test_bowling.py ......                                                   [100%]

============================== 6 passed in 0.01s ===============================
examples/cycles/cycle-6-red.txt (943 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 6 items

test_bowling.py .....F                                                   [100%]

=================================== FAILURES ===================================
________________ test_a_frame_of_more_than_ten_pins_is_refused _________________

    def test_a_frame_of_more_than_ten_pins_is_refused():
>       with pytest.raises(bowling.ScoringError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE ScoringError

test_bowling.py:32: Failed
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_frame_of_more_than_ten_pins_is_refused - Faile...
========================= 1 failed, 5 passed in 0.02s ==========================
examples/cycles/cycle-7-green.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 7 items

test_bowling.py .......                                                  [100%]

============================== 7 passed in 0.01s ===============================
examples/cycles/cycle-7-red.txt (1536 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 7 items

test_bowling.py ......F                                                  [100%]

=================================== FAILURES ===================================
____________ test_a_game_with_the_wrong_number_of_rolls_is_refused _____________

    def test_a_game_with_the_wrong_number_of_rolls_is_refused():
        with pytest.raises(bowling.ScoringError):
>           bowling.score([0] * 19)

test_bowling.py:38: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

rolls = [0, 0, 0, 0, 0, 0, ...]

    def score(rolls):
        """Total score for a completed ten-frame game, given every roll in order."""
        _check_pins(rolls)
        total = 0
        roll = 0
        for _frame in range(10):
            if _is_strike(rolls, roll):
                total += 10 + rolls[roll + 1] + rolls[roll + 2]
                roll += 1
            else:
>               pins = rolls[roll] + rolls[roll + 1]
                                     ^^^^^^^^^^^^^^^
E               IndexError: list index out of range

bowling.py:18: IndexError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_game_with_the_wrong_number_of_rolls_is_refused
========================= 1 failed, 6 passed in 0.02s ==========================
examples/cycles/cycle-8-mutant.txt (5246 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 9 items

test_bowling.py ..F....FF                                                [100%]

=================================== FAILURES ===================================
____________________ test_a_strike_adds_the_next_two_rolls _____________________

    def test_a_strike_adds_the_next_two_rolls():
>       assert bowling.score([10, 3, 4] + [0] * 16) == 24
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

test_bowling.py:17: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

rolls = [10, 3, 4, 0, 0, 0, ...]

    def score(rolls):
        """Total score for a completed ten-frame game, given every roll in order."""
        _check_pins(rolls)
        total = 0
        roll = 0
        bonus_rolls = 0
        for frame in range(1, 11):
            if roll >= len(rolls):
                raise ScoringError(f"a game has ten frames; the rolls stop before frame {frame}")
            if False:
                if roll + 2 >= len(rolls):
                    raise ScoringError(f"the strike in frame {frame} has no two bonus rolls after it")
                total += 10 + rolls[roll + 1] + rolls[roll + 2]
                bonus_rolls = 2
                roll += 1
            else:
                if roll + 1 >= len(rolls):
                    raise ScoringError(f"frame {frame} has only one roll")
                pins = rolls[roll] + rolls[roll + 1]
                if pins > 10:
>                   raise ScoringError(f"a frame knocks down at most 10 pins, frame {frame} has {pins}")
E                   bowling.ScoringError: a frame knocks down at most 10 pins, frame 1 has 13

bowling.py:28: ScoringError
___________________ test_a_perfect_game_scores_three_hundred ___________________

    def test_a_perfect_game_scores_three_hundred():
>       assert bowling.score([10] * 12) == 300
               ^^^^^^^^^^^^^^^^^^^^^^^^

test_bowling.py:44: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

rolls = [10, 10, 10, 10, 10, 10, ...]

    def score(rolls):
        """Total score for a completed ten-frame game, given every roll in order."""
        _check_pins(rolls)
        total = 0
        roll = 0
        bonus_rolls = 0
        for frame in range(1, 11):
            if roll >= len(rolls):
                raise ScoringError(f"a game has ten frames; the rolls stop before frame {frame}")
            if False:
                if roll + 2 >= len(rolls):
                    raise ScoringError(f"the strike in frame {frame} has no two bonus rolls after it")
                total += 10 + rolls[roll + 1] + rolls[roll + 2]
                bonus_rolls = 2
                roll += 1
            else:
                if roll + 1 >= len(rolls):
                    raise ScoringError(f"frame {frame} has only one roll")
                pins = rolls[roll] + rolls[roll + 1]
                if pins > 10:
>                   raise ScoringError(f"a frame knocks down at most 10 pins, frame {frame} has {pins}")
E                   bowling.ScoringError: a frame knocks down at most 10 pins, frame 1 has 20

bowling.py:28: ScoringError
_________________________ test_a_real_game_scores_133 __________________________

    def test_a_real_game_scores_133():
        rolls = [1, 4, 4, 5, 6, 4, 5, 5, 10, 0, 1, 7, 3, 6, 4, 10, 2, 8, 6]
>       assert bowling.score(rolls) == 133
               ^^^^^^^^^^^^^^^^^^^^

test_bowling.py:49: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

rolls = [1, 4, 4, 5, 6, 4, ...]

    def score(rolls):
        """Total score for a completed ten-frame game, given every roll in order."""
        _check_pins(rolls)
        total = 0
        roll = 0
        bonus_rolls = 0
        for frame in range(1, 11):
            if roll >= len(rolls):
                raise ScoringError(f"a game has ten frames; the rolls stop before frame {frame}")
            if False:
                if roll + 2 >= len(rolls):
                    raise ScoringError(f"the strike in frame {frame} has no two bonus rolls after it")
                total += 10 + rolls[roll + 1] + rolls[roll + 2]
                bonus_rolls = 2
                roll += 1
            else:
                if roll + 1 >= len(rolls):
                    raise ScoringError(f"frame {frame} has only one roll")
                pins = rolls[roll] + rolls[roll + 1]
                if pins > 10:
>                   raise ScoringError(f"a frame knocks down at most 10 pins, frame {frame} has {pins}")
E                   bowling.ScoringError: a frame knocks down at most 10 pins, frame 8 has 14

bowling.py:28: ScoringError
=========================== short test summary info ============================
FAILED test_bowling.py::test_a_strike_adds_the_next_two_rolls - bowling.Scori...
FAILED test_bowling.py::test_a_perfect_game_scores_three_hundred - bowling.Sc...
FAILED test_bowling.py::test_a_real_game_scores_133 - bowling.ScoringError: a...
========================= 3 failed, 6 passed in 0.02s ==========================
examples/cycles/cycle-8-passed-immediately.txt (391 bytes)
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/bowling-kata
plugins: cov-7.1.0, anyio-4.14.2
collected 9 items

test_bowling.py .........                                                [100%]

============================== 9 passed in 0.01s ===============================
examples/cycles/README.md (6306 bytes)
# The recorded history of this kata

Every `.txt` file beside this one is unedited output from a real `pytest` run
made while writing `../bowling.py` and `../test_bowling.py` one cycle at a
time. Nothing here was typed by hand or reconstructed afterwards, and the test
runner checks that: `tests/run_tests.sh` reads each file and refuses to pass
unless the RED capture really reports a failure and the GREEN capture really
reports the exact number of passing tests that cycle should have reached.

The runs were made in a scratch directory called `/private/tmp/bowling-kata`,
which is why that path appears on the `rootdir:` line. Your own runs will show
your lab directory there instead. Every other line will match.

| File | What it is | Summary line |
| --- | --- | --- |
| `cycle-1-red.txt` | one test, no implementation at all | `1 failed` |
| `cycle-1-green.txt` | after `return 0` | `1 passed` |
| `cycle-2-red.txt` | the constant meets a second example | `1 failed, 1 passed` |
| `cycle-2-green.txt` | after `return sum(rolls)` | `2 passed` |
| `cycle-3-red.txt` | summing rolls cannot score a strike | `1 failed, 2 passed` |
| `cycle-3-green.txt` | after the frame walk with a strike branch | `3 passed` |
| `cycle-4-red.txt` | the frame walk scores a spare as an open frame | `1 failed, 3 passed` |
| `cycle-4-green.txt` | after the spare branch | `4 passed` |
| `cycle-4-refactor.txt` | after extracting `_is_strike` and `_is_spare` | `4 passed` |
| `cycle-5-red.txt` | the module has no `ScoringError` yet | `1 failed, 4 passed` |
| `cycle-5-green.txt` | after the error class and the per-roll check | `5 passed` |
| `cycle-6-red.txt` | twelve pins in one frame sail through | `1 failed, 5 passed` |
| `cycle-6-green.txt` | after the per-frame check | `6 passed` |
| `cycle-7-red.txt` | a short game leaks an `IndexError` | `1 failed, 6 passed` |
| `cycle-7-green.txt` | after the length and bonus-roll rules | `7 passed` |
| `cycle-8-passed-immediately.txt` | two new tests, both green on the first run | `9 passed` |
| `cycle-8-mutant.txt` | the same nine tests against a deliberately broken copy | `3 failed, 6 passed` |

## What each cycle actually added

**Cycle 1 — a gutter game scores zero.** The red is an `AttributeError`:
`module 'bowling' has no attribute 'score'`. The module existed; the function
did not. The green was `return 0` — a fake, written knowingly, because one
example cannot distinguish a constant from a calculation.

**Cycle 2 — all ones scores twenty.** The red is
`AssertionError: assert 0 == 20`. Two examples now pin the behaviour down
enough that a constant cannot satisfy both. That is triangulation, and the
green is `return sum(rolls)` — still nowhere near a bowling scorer, but
exactly as much code as the two tests demand.

**Cycle 3 — a strike adds the next two rolls.** The red is
`assert 17 == 24`. This is the cycle that changed the *shape* of the code:
adding rolls cannot express "the next two rolls count twice", so the
implementation became a ten-iteration loop over frames with a roll index that
advances by one after a strike and two otherwise. Notice what did not happen:
no spare branch appeared, because nothing yet asked for one.

**Cycle 4 — a spare adds the next roll.** The red is `assert 13 == 16` — the
frame walker scores the 5 and 5 as an ordinary open frame worth ten. The green
inserted one `elif`. Then, with four tests green, `_is_strike` and `_is_spare`
were extracted and the suite re-run unchanged: `cycle-4-refactor.txt` is the
proof that the refactor changed nothing.

**Cycle 5 — a roll outside zero to ten.** The red is another `AttributeError`,
this time for `bowling.ScoringError`. A test may legitimately demand a name
that does not exist yet; that is the test specifying an interface. The green
added the exception class and a `_check_pins` pass over the rolls.

**Cycle 6 — a frame of more than ten pins.** The red is
`Failed: DID NOT RAISE ScoringError`, pytest's way of saying the code sailed
straight past something it should have stopped at. The green put the check in
the open-frame branch, which is the only place that can see both rolls of a
frame.

**Cycle 7 — the wrong number of rolls.** The red is the most interesting one
in the set: the call does not fail politely, it raises `IndexError: list index
out of range` from inside the loop, and pytest prints the whole function with
an arrow at the offending line. An unhandled `IndexError` is a bug escaping
through a public interface. The green replaced it with three stated refusals —
rolls that run out mid-game, a tenth-frame strike or spare with no bonus rolls
behind it, and extra rolls after frame ten — and added the `bonus_rolls`
bookkeeping that makes a perfect game twelve rolls long rather than ten.

**Cycle 8 — the two tests that passed immediately.** A perfect game and a real
133-point game were added together and both passed on the first run. That is
not evidence the tests work; it is evidence of nothing at all until the tests
have been seen to fail. So `if _is_strike(rolls, roll):` was replaced with
`if False:` in a copy, and the suite was run again: three tests went red,
including both new ones. `cycle-8-mutant.txt` is that run. Only after seeing
it were the two tests worth keeping.

## The honest part

Two things in this record are worth being sceptical about, and both are
deliberate.

The first is that a recorded history is easy to fake, and a suite of tests that
were all written after the fact would look identical in the finished
repository. That is precisely why the runner checks the pass counts: cycle 5's
red must show four other tests already passing, which is only true if the
earlier cycles really happened first and really ended green. It is not
proof — nothing short of watching over your shoulder is — but reconstructing
seventeen consistent captures is more work than doing the kata.

The second is that this kata is unusually well-suited to test-first work.
`score` is a pure function over a list of integers with a knowable contract,
which is the easiest possible case. Day 73's lesson is explicit about where
this technique stops paying: exploratory work, code whose shape you genuinely
do not know yet, user interfaces, and anything where writing the assertion is
the hard part.
examples/test_bowling.py (1272 bytes)
"""The bowling scorer, one failing test at a time."""

import pytest

import bowling


def test_a_gutter_game_scores_zero():
    assert bowling.score([0] * 20) == 0


def test_a_game_of_all_ones_scores_twenty():
    assert bowling.score([1] * 20) == 20


def test_a_strike_adds_the_next_two_rolls():
    assert bowling.score([10, 3, 4] + [0] * 16) == 24


def test_a_spare_adds_the_next_roll():
    assert bowling.score([5, 5, 3] + [0] * 17) == 16


def test_a_roll_outside_zero_to_ten_is_refused():
    with pytest.raises(bowling.ScoringError):
        bowling.score([11] + [0] * 19)
    with pytest.raises(bowling.ScoringError):
        bowling.score([-1] + [0] * 19)


def test_a_frame_of_more_than_ten_pins_is_refused():
    with pytest.raises(bowling.ScoringError):
        bowling.score([7, 5] + [0] * 18)


def test_a_game_with_the_wrong_number_of_rolls_is_refused():
    with pytest.raises(bowling.ScoringError):
        bowling.score([0] * 19)
    with pytest.raises(bowling.ScoringError):
        bowling.score([0] * 21)


def test_a_perfect_game_scores_three_hundred():
    assert bowling.score([10] * 12) == 300


def test_a_real_game_scores_133():
    rolls = [1, 4, 4, 5, 6, 4, 5, 5, 10, 0, 1, 7, 3, 6, 4, 10, 2, 8, 6]
    assert bowling.score(rolls) == 133
metadata.yml (1104 bytes)
lesson_id: D073
day: 73
kind: python-program
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-073-test-driven-development
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/pytest --version
run_commands:
  - cat starter/cycles.md
  - .venv/bin/pytest starter/test_bowling.py
  - .venv/bin/pytest examples/test_bowling.py
  - .venv/bin/pytest examples/test_bowling.py -q --collect-only
  - cat examples/cycles/cycle-1-red.txt
  - cat examples/cycles/cycle-7-red.txt
  - cat examples/cycles/README.md
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - "find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +"
  - rm -rf .venv
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, pytest 9.1.1, bash 3.2.57 — bash tests/run_tests.sh -> 40 checks, 0 failure(s), exit 0 (41 checks, 0 failure(s) with the kata completed in starter/)'
requirements/README.md (2481 bytes)
# Dependencies — Day 073 lab

**One third-party package: pytest. No network at test time, no API key, no
account.**

```text
pytest==9.1.1
```

That is the whole of `requirements.txt`, pinned to an exact version so the
output you see matches the captures in `expected-output/` and
`examples/cycles/` line for line. pytest is free and open source under the MIT
licence — its documentation is listed in the lesson's sources, and
`pytest --version` on this machine printed `pytest 9.1.1` on 2026-07-19.

## The one-time install

You built a virtual environment on Day 43; this is the same three lines.

```bash
cd labs/sections/programming-with-python/day-073-test-driven-development
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
```

The `pip install` step is the only moment this lab touches the network.
Afterwards every command — the kata, the suite, the test runner — runs fully
offline. `.venv/` is ignored by version control; never commit it.

If you would rather not create a lab-local environment, point the runner at a
pytest you already have:

```bash
PYTEST=/path/to/pytest bash tests/run_tests.sh
```

`tests/run_tests.sh` looks for that override first, then `./.venv/bin/pytest`,
then whatever is on your `PATH`, and stops with instructions if it finds none.

## What else the lab uses

| Module | Used in | Why |
| --- | --- | --- |
| `pytest` | `examples/test_bowling.py`, your `starter/test_bowling.py` | test discovery, plain `assert`, and `pytest.raises` for the three refusals |
| `bash` | `tests/run_tests.sh` | the outer runner, so the command is the same as every other day of this course |
| `sed` | `tests/run_tests.sh` | breaking a throwaway copy of the implementation, to prove the suite can fail |
| standard library only | `examples/bowling.py` | the scorer imports nothing at all |

The implementation under test has **no imports**. That is not an accident: a
pure function over a list of integers is the easiest thing in the world to
drive test-first, which is exactly why this kata was chosen to teach the
technique. Day 74 takes on the harder case, where the code you are testing
talks to something outside itself.

## Windows

Use WSL and follow the Linux path, or substitute `python` for `python3` and
`.venv\Scripts\pytest.exe` for `.venv/bin/pytest`. The test runner is a bash
script, so it needs WSL, Git Bash, or another bash. Nothing about the kata
itself is platform-specific.
requirements/requirements.txt (14 bytes)
pytest==9.1.1
starter/bowling.py (226 bytes)
"""Bowling scoring — the module you grow one failing test at a time.

Deliberately empty. In test-driven development the implementation does not
exist until a failing test asks for it. Work through `cycles.md` in order.
"""
starter/cycles.md (8410 bytes)
# The kata sheet — score a game of ten-pin bowling, one cycle at a time

You are writing `score(rolls)`: given the pins knocked down by every roll of a
completed ten-frame game, in order, return the total score. Nothing else.

**The rules of bowling you need, and no more.** A game is ten frames. In each
frame you get two rolls to knock down ten pins. Knock all ten down with the
first roll and that is a **strike**: the frame scores ten plus your next two
rolls, and you do not roll again in that frame. Knock all ten down across both
rolls and that is a **spare**: the frame scores ten plus your next one roll.
Otherwise the frame scores the pins you knocked down. If the tenth frame is a
strike or a spare you roll the bonus balls at the end of the list, and they
count only as bonus — they are not an eleventh frame.

**The one rule of this lab.** One test per cycle. Run it. Watch it fail. Read
the failure. Only then write code, and only enough code to make it pass.

---

## How to run a cycle

From the lab directory (the one containing `starter/` and `examples/`):

```bash
.venv/bin/pytest starter/test_bowling.py
```

Every cycle below has three parts.

- **The test** — the one test function to add to `starter/test_bowling.py`.
  Add it at the bottom. Never add two at once.
- **RED** — run the suite before touching `bowling.py`, and paste the output
  into the RED block. Then answer the question under it in one sentence.
- **GREEN** — write the least code that passes, run again, paste that output
  into the GREEN block.

Your GREEN block for cycle N must say `N passed`. If it says anything else you
have either skipped a cycle or written more than one test.

---

## Cycle 1 — a gutter game scores zero

Twenty rolls, no pins.

```python
def test_a_gutter_game_scores_zero():
    assert bowling.score([0] * 20) == 0
```

RED — run it now, before writing any code at all.

```text

```

Which line of the failure tells you the test reached your module and found
nothing there?

GREEN — the least code. Yes, `return 0` is allowed here; it is the technique
called **fake it till you make it**, and cycle 2 is what removes the fake.

```text

```

## Cycle 2 — a game of all ones scores twenty

Twenty rolls, one pin each.

```python
def test_a_game_of_all_ones_scores_twenty():
    assert bowling.score([1] * 20) == 20
```

RED — the fake from cycle 1 dies here. That is the whole point of a second
example: **triangulation**, two data points that no constant can satisfy.

```text

```

GREEN — now the constant has to become an expression. Resist writing frames:
`sum(rolls)` passes both tests and no test yet asks for more.

```text

```

## Cycle 3 — a strike adds the next two rolls

A strike in the first frame, then a 3 and a 4, then nothing.
Work the arithmetic by hand before you run anything: frame 1 scores
10 + 3 + 4 = 17, frame 2 scores 3 + 4 = 7, frames 3 to 10 score 0.
Total 24. Note that the 3 and the 4 are counted twice, on purpose.

```python
def test_a_strike_adds_the_next_two_rolls():
    assert bowling.score([10, 3, 4] + [0] * 16) == 24
```

RED — `sum` returns 17. The failure message shows you both numbers.

```text

```

GREEN — this is the cycle where the design has to change: you can no longer
add rolls, you must walk frames. Introduce a roll index and a ten-frame loop,
with one branch for a strike. Do not write the spare branch yet; no test asks
for it.

```text

```

## Cycle 4 — a spare adds the next roll

Five and five, then a 3.
Frame 1 scores 10 + 3 = 13, frame 2 scores 3 + 0 = 3, the rest score 0.
Total 16.

```python
def test_a_spare_adds_the_next_roll():
    assert bowling.score([5, 5, 3] + [0] * 17) == 16
```

RED — your frame walker treats 5 and 5 as an ordinary open frame worth 10, so
it reports 13.

```text

```

GREEN — add the spare branch between the strike branch and the open frame.

```text

```

REFACTOR — the safety net is now four tests wide, so use it. Pull the two
conditions out into `_is_strike(rolls, roll)` and `_is_spare(rolls, roll)`,
change nothing about the behaviour, and run again. A refactor that changes a
single character of output is not a refactor; it is an untested edit.

```text

```

## Cycle 5 — a roll outside zero to ten is refused

Bowling has ten pins. Eleven is not a score, it is bad data, and a scorer that
quietly totals bad data is worse than one that stops. Refuse it with a
`ScoringError` your own module defines.

```python
def test_a_roll_outside_zero_to_ten_is_refused():
    with pytest.raises(bowling.ScoringError):
        bowling.score([11] + [0] * 19)
    with pytest.raises(bowling.ScoringError):
        bowling.score([-1] + [0] * 19)
```

RED — read this failure especially carefully. It is an `AttributeError`, not a
scoring mistake, because `bowling.ScoringError` does not exist yet. That is
still the right reason to fail: the test is asking for a name the module has
not got.

```text

```

GREEN — define `class ScoringError(Exception)` and check every roll before
scoring anything.

```text

```

## Cycle 6 — a frame of more than ten pins is refused

Seven pins then five pins is twelve pins in one frame, from ten pins on the
deck. Impossible.

```python
def test_a_frame_of_more_than_ten_pins_is_refused():
    with pytest.raises(bowling.ScoringError):
        bowling.score([7, 5] + [0] * 18)
```

RED — this one fails with `Failed: DID NOT RAISE ScoringError`, which is the
message you get when the code sails past something it should have refused.

```text

```

GREEN — check the pair inside the open-frame branch. Careful: the check has to
sit where it can see both rolls of the frame, and it must not fire on the
bonus rolls after a tenth-frame strike.

```text

```

## Cycle 7 — a game with the wrong number of rolls is refused

Nineteen gutter balls is not a game; neither is twenty-one.

```python
def test_a_game_with_the_wrong_number_of_rolls_is_refused():
    with pytest.raises(bowling.ScoringError):
        bowling.score([0] * 19)
    with pytest.raises(bowling.ScoringError):
        bowling.score([0] * 21)
```

RED — the short game does not fail politely, it explodes with an `IndexError`
from deep inside your loop. An unhandled `IndexError` is a bug leaking through
your interface, and this test is what turns it into a stated refusal.

```text

```

GREEN — this is the largest cycle, and the one worth thinking about before
typing. You need to know, at the end of frame ten, how many bonus rolls the
game is entitled to: two after a strike, one after a spare, none otherwise.
Anything else left over is an extra roll. Anything missing is a short game.

```text

```

## Cycle 8 — the two tests that pass immediately

Now add both of these at once, on purpose, and run.

```python
def test_a_perfect_game_scores_three_hundred():
    assert bowling.score([10] * 12) == 300


def test_a_real_game_scores_133():
    rolls = [1, 4, 4, 5, 6, 4, 5, 5, 10, 0, 1, 7, 3, 6, 4, 10, 2, 8, 6]
    assert bowling.score(rolls) == 133
```

They pass on the first run. Nine passed, nothing red.

```text

```

That is not a victory, it is an unanswered question: **a test that has never
been seen to fail has never been shown to be connected to anything.** So make
it fail on purpose. Copy `bowling.py` somewhere temporary, replace
`if _is_strike(rolls, roll):` with `if False:`, run the suite against the
broken copy, and confirm both new tests go red.

```text

```

Put the good file back. You have now done, by hand, exactly what the test
runner in `tests/run_tests.sh` does automatically — and what you must do every
single time you write a test after the code it tests.

---

## After the kata — write down what you learned

Answer these in your own words, in this file, before you look at
`examples/cycles/README.md`.

1. Which cycle changed the *shape* of your implementation rather than adding to
   it, and what did the test that forced it look like?

```text

```

2. In cycle 2, was `sum(rolls)` the right amount of code, or too little? Give
   the argument for the other side too.

```text

```

3. Cycle 5 and cycle 7 both failed for reasons that were not scoring mistakes
   (a missing name, a leaked `IndexError`). Explain why each was nonetheless
   the right reason to fail.

```text

```

4. Name one thing about this kata that test-first made harder rather than
   easier.

```text

```
starter/test_bowling.py (1247 bytes)
"""Your bowling suite. One test arrives per cycle — never two.

Read `cycles.md` and work through it in order. For each cycle:

  1. Add exactly ONE test function here, at the bottom, in cycle order.
  2. Run the suite and WATCH IT FAIL. Read the failure. Is it failing for the
     reason you expected, or for a boring reason like a typo in the import?
  3. Paste that failure into the RED slot for the cycle in `cycles.md`.
  4. Write the least code in `bowling.py` that makes it pass.
  5. Run the suite again, confirm every test passes, and paste that run into
     the GREEN slot in `cycles.md`.

Run the suite from the lab directory (the directory that holds `starter/`):

    .venv/bin/pytest starter/test_bowling.py

The import below is deliberately `import bowling` rather than
`from bowling import score`. A missing name then fails inside the one test
that uses it, instead of stopping collection for the whole file — which is
what lets each cycle show up as "1 failed, N passed" rather than one error.
"""

import pytest  # noqa: F401  — you need this from cycle 5 onward

import bowling  # noqa: F401  — your implementation module, empty for now


# Cycle 1 starts here. Delete this comment and write your first test function.
tests/run_tests.sh (10975 bytes)
#!/usr/bin/env bash
# Tests for the Day 073 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Three things are being checked here, and the third is the point of the day:
#
#   1. the finished suite passes (pytest exits 0);
#   2. the suite has teeth — a reference implementation broken by one `sed`
#      substitution in a throwaway copy makes pytest exit non-zero;
#   3. the RECORDED HISTORY in examples/cycles/ is genuine — every RED capture
#      really reports a failure, every GREEN capture really reports a pass, and
#      each cycle's pass count is exactly what a real red-green sequence would
#      have produced at that point. A suite written after the fact cannot
#      produce a consistent chain of those counts.
#
# No network, non-interactive, deterministic. Exits 0 only if every check
# passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cycles_dir="${lab_dir}/examples/cycles"
failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# Resolve pytest: an explicit override, then this lab's .venv, then whatever
# is on PATH. Fails loudly with instructions rather than silently skipping.
resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install it with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

run_pytest() {
  # run_pytest <dir> — run every test in one directory, quietly. Prints
  # pytest's output; the caller reads the exit code.
  local dir="$1"
  (cd "${dir}" && "${pytest_bin}" -p no:cacheprovider -q . 2>&1)
}

echo "Using pytest: $("${pytest_bin}" --version 2>&1 | head -1)"

# --- 1. The finished suite passes ------------------------------------------
echo "Testing the reference suite ..."

out="$(run_pytest "${lab_dir}/examples")"
code=$?
if [ "${code}" -eq 0 ] && printf '%s' "${out}" | grep -qE '9 passed'; then
  check "examples/test_bowling.py: 9 tests, all passing" "yes"
else
  check "examples/test_bowling.py: 9 tests, all passing" "no"
  echo "    (exit ${code}; output: ${out})"
fi

collected="$(cd "${lab_dir}/examples" && "${pytest_bin}" -p no:cacheprovider --collect-only -q . 2>/dev/null)"
for name in test_a_gutter_game_scores_zero \
  test_a_strike_adds_the_next_two_rolls \
  test_a_spare_adds_the_next_roll \
  test_a_roll_outside_zero_to_ten_is_refused \
  test_a_game_with_the_wrong_number_of_rolls_is_refused \
  test_a_perfect_game_scores_three_hundred; do
  if printf '%s' "${collected}" | grep -qF "${name}"; then
    check "the suite collects ${name}" "yes"
  else
    check "the suite collects ${name}" "no"
  fi
done

# --- 2. The suite has teeth -------------------------------------------------
# A test suite that cannot fail is not a test suite. Break the reference
# implementation one substitution at a time, in a throwaway copy, and insist
# that pytest notices.
echo "Testing that the suite fails on a broken implementation ..."

mutate() {
  local label="$1" expression="$2" work_dir out code
  work_dir="$(mktemp -d "${TMPDIR:-/tmp}/bowling-mutant.XXXXXX")"
  cp "${lab_dir}/examples/test_bowling.py" "${work_dir}/test_bowling.py"
  sed "${expression}" "${lab_dir}/examples/bowling.py" > "${work_dir}/bowling.py"
  if cmp -s "${lab_dir}/examples/bowling.py" "${work_dir}/bowling.py"; then
    check "${label}" "no"
    echo "    (the sed expression changed nothing — the reference no longer has that line)"
    rm -rf "${work_dir}"
    return
  fi
  out="$(cd "${work_dir}" && "${pytest_bin}" -p no:cacheprovider -q . 2>&1)"
  code=$?
  if [ "${code}" -ne 0 ] && printf '%s' "${out}" | grep -qE '[0-9]+ failed'; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (a broken implementation still passed; exit ${code})"
  fi
  rm -rf "${work_dir}"
}

mutate "an off-by-one in the total is caught" 's/^    return total$/    return total + 1/'
mutate "removing the frame-size refusal is caught" 's/if pins > 10:/if pins > 99:/'
mutate "removing the per-roll refusal is caught" 's/if pins < 0 or pins > 10:/if False:/'
mutate "treating every strike as an open frame is caught" 's/if _is_strike(rolls, roll):/if False:/'

# --- 3. The recorded history is genuine -------------------------------------
# This is the check that belongs to this day and no other. For each cycle:
#
#   * the RED capture must report exactly one failing test, and (from cycle 2
#     on) exactly the number of already-passing tests the earlier cycles left
#     behind;
#   * the GREEN capture must report that same number plus one passing, with no
#     failures at all.
#
# Those counts chain: cycle 5's red can only say "4 passed" if cycles 1-4 were
# really written first and really ended green. A history assembled after the
# code was finished does not produce that chain by accident.
echo "Testing that examples/cycles/ is a genuine red-green record ..."

summary_line() {
  grep -E '^=+ .*(passed|failed|error).* =+$' "$1" | tail -1
}

check_red() {
  local n="$1" file="${cycles_dir}/cycle-$1-red.txt" line already
  already=$((n - 1))
  if [ ! -f "${file}" ]; then
    check "cycle ${n} RED capture exists" "no"
    return
  fi
  line="$(summary_line "${file}")"
  if ! printf '%s' "${line}" | grep -qE '(^| )1 failed(,| )'; then
    check "cycle ${n} RED really failed" "no"
    echo "    (summary line was: ${line})"
    return
  fi
  if [ "${already}" -gt 0 ] && ! printf '%s' "${line}" | grep -qE "(^| )${already} passed"; then
    check "cycle ${n} RED shows the ${already} earlier cycles still green" "no"
    echo "    (summary line was: ${line})"
    return
  fi
  check "cycle ${n} RED: 1 failed, ${already} passed" "yes"
}

check_green() {
  local n="$1" label="${2:-green}" expected="$3" file line
  file="${cycles_dir}/cycle-$1-${label}.txt"
  if [ ! -f "${file}" ]; then
    check "cycle ${n} ${label} capture exists" "no"
    return
  fi
  line="$(summary_line "${file}")"
  if printf '%s' "${line}" | grep -qE '(failed|error)'; then
    check "cycle ${n} ${label} really passed" "no"
    echo "    (summary line was: ${line})"
    return
  fi
  if ! printf '%s' "${line}" | grep -qE "(^| )${expected} passed"; then
    check "cycle ${n} ${label} shows ${expected} passing" "no"
    echo "    (summary line was: ${line})"
    return
  fi
  check "cycle ${n} ${label}: ${expected} passed, 0 failed" "yes"
}

for n in 1 2 3 4 5 6 7; do
  check_red "${n}"
  check_green "${n}" green "${n}"
done

# The refactor after cycle 4 must leave the count untouched — that is what
# makes it a refactor rather than an edit.
check_green 4 refactor 4

# Cycle 8 is the day's punchline: two tests that passed on the first run, and
# then the same two tests failing against a deliberately broken copy. Without
# the second file the first proves nothing.
check_green 8 passed-immediately 9

mutant_line="$(summary_line "${cycles_dir}/cycle-8-mutant.txt" 2>/dev/null)"
if printf '%s' "${mutant_line}" | grep -qE '[0-9]+ failed' \
  && grep -qF 'test_a_perfect_game_scores_three_hundred' "${cycles_dir}/cycle-8-mutant.txt" \
  && grep -qF 'test_a_real_game_scores_133' "${cycles_dir}/cycle-8-mutant.txt"; then
  check "cycle 8 mutant: both tests that passed immediately were shown to fail" "yes"
else
  check "cycle 8 mutant: both tests that passed immediately were shown to fail" "no"
  echo "    (summary line was: ${mutant_line})"
fi

# A capture nobody can read is a capture nobody checked. Every recorded run
# must name the pytest that produced it, so the record is attributable.
bad_version=0
for file in "${cycles_dir}"/cycle-*.txt; do
  grep -qF 'pytest-9.1.1' "${file}" || bad_version=$((bad_version + 1))
done
if [ "${bad_version}" -eq 0 ]; then
  check "every capture records the pytest version that produced it" "yes"
else
  check "every capture records the pytest version that produced it" "no"
  echo "    (${bad_version} capture(s) without a version banner)"
fi

# --- 4. The starter ---------------------------------------------------------
echo "Testing starter/ ..."

for f in "${lab_dir}/starter/bowling.py" "${lab_dir}/starter/test_bowling.py"; do
  if python3 -c "import sys; compile(open(sys.argv[1]).read(), sys.argv[1], 'exec')" "${f}" 2>/dev/null; then
    check "$(basename "${f}") is valid Python" "yes"
  else
    check "$(basename "${f}") is valid Python" "no"
  fi
done

for heading in 'Cycle 1' 'Cycle 2' 'Cycle 3' 'Cycle 4' 'Cycle 5' 'Cycle 6' 'Cycle 7' 'Cycle 8'; do
  if grep -qF "## ${heading}" "${lab_dir}/starter/cycles.md"; then
    check "cycles.md dictates ${heading}" "yes"
  else
    check "cycles.md dictates ${heading}" "no"
  fi
done

if grep -qE '^def score' "${lab_dir}/starter/bowling.py"; then
  # The learner has done the work. Hold their implementation to exactly the
  # same standard as the reference: run THEIR module against the reference
  # suite, so a weaker set of tests cannot let a weaker implementation past.
  echo "starter/bowling.py defines score() — grading it against the reference suite ..."
  work_dir="$(mktemp -d "${TMPDIR:-/tmp}/bowling-starter.XXXXXX")"
  cp "${lab_dir}/starter/bowling.py" "${work_dir}/bowling.py"
  cp "${lab_dir}/examples/test_bowling.py" "${work_dir}/test_bowling.py"
  out="$(cd "${work_dir}" && "${pytest_bin}" -p no:cacheprovider -q . 2>&1)"
  code=$?
  if [ "${code}" -eq 0 ]; then
    check "starter/bowling.py passes the reference suite" "yes"
  else
    check "starter/bowling.py passes the reference suite" "no"
    echo "    (exit ${code}; output: ${out})"
  fi
  rm -rf "${work_dir}"

  own="$(cd "${lab_dir}/starter" && "${pytest_bin}" -p no:cacheprovider -q . 2>&1)"
  code=$?
  if [ "${code}" -eq 0 ] && printf '%s' "${own}" | grep -qE '[0-9]+ passed'; then
    check "your own suite in starter/ passes" "yes"
  else
    check "your own suite in starter/ passes" "no"
    echo "    (exit ${code}; output: ${own})"
  fi
else
  echo "Note: starter/bowling.py is still empty — the kata has not been started."
  if [ ! -s "${lab_dir}/starter/bowling.py" ] || ! grep -qE '^(def|class) ' "${lab_dir}/starter/bowling.py"; then
    check "starter/bowling.py is the empty module the kata begins from" "yes"
  else
    check "starter/bowling.py is the empty module the kata begins from" "no"
  fi
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 073 lab

FAIL: pytest not found.

The runner looked in three places and found nothing. Create the environment:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Or point it at a pytest you already have: PYTEST=/path/to/pytest bash tests/run_tests.sh.

ModuleNotFoundError: No module named 'bowling'

You ran pytest from the wrong directory, or you moved the test file away from the module it imports. pytest puts the test file's own directory at the front of the import path, so starter/test_bowling.py finds starter/bowling.py and examples/test_bowling.py finds examples/bowling.py. Run from the lab directory:

.venv/bin/pytest starter/test_bowling.py

import file mismatch when you run both suites at once

.venv/bin/pytest with no arguments collects starter/test_bowling.py and examples/test_bowling.py, two files with the same module name and no package around them. pytest cannot import both. Name the one you want:

.venv/bin/pytest starter/test_bowling.py     # your work
.venv/bin/pytest examples/test_bowling.py    # the reference

no tests ran in 0.01s and an exit code of 5

Expected, before cycle 1. starter/test_bowling.py has no test functions yet, and pytest reserves exit code 5 for "nothing to run". Write the cycle 1 test and it becomes a proper failure.

AttributeError: module 'bowling' has no attribute 'score'

The correct first red of the whole kata. Your test asked for a name your module has not got. Cycle 5 produces the same message for ScoringError, for the same good reason.

Failed: DID NOT RAISE ScoringError

pytest's way of saying the code inside pytest.raises(...) finished normally when you expected a refusal. In cycle 6 that is the red you are looking for. If you see it after writing the check, the check is in the wrong branch — a strike never reaches the open-frame path where the twelve-pin test lives.

IndexError: list index out of range inside score

The cycle 7 red, and worth pausing over. The failure is real, but it is your implementation crashing rather than refusing. The whole purpose of cycle 7 is to turn that crash into a stated ScoringError.

My cycle 5 test kills the whole file instead of one test

You wrote from bowling import score, ScoringError at the top. A missing name in a from ... import fails at collection time and takes every test in the file with it, so the summary line says 1 error rather than 1 failed, 4 passed. Use import bowling and reach for bowling.score and bowling.ScoringError inside the tests, as starter/test_bowling.py does.

The perfect-game test passed on the first run and I do not trust it

Correct instinct — that is cycle 8. Copy bowling.py to a temporary directory, replace if _is_strike(rolls, roll): with if False:, run the suite against the copy, and watch the test go red. Then put the good file back. A test you have never seen fail is a test you have no evidence about.

cycle N RED shows the N-1 earlier cycles still green failed

The runner is reading examples/cycles/, not your work, so this can only mean a capture file in that directory was edited or replaced. Restore it with git checkout -- examples/cycles/.

My own RED block shows two failing tests

You added two test functions in one cycle. Delete one, re-run, and record the cycle properly. One test per cycle is the entire discipline being practised; two tests at once is how people end up writing code that no single test actually pins down.

My own GREEN block shows fewer passing tests than the cycle number

You deleted or renamed an earlier test, or an earlier one has started failing because of the code you just wrote. The second case is the more interesting one: your new code broke old behaviour, which is exactly the event a regression suite exists to report. Fix it before moving on; never comment out a red test to get to the next cycle.

sed: 1: "...": bad flag in substitute command

You are on macOS, where sed -i needs an explicit backup suffix (sed -i ''). The test runner never edits in place — it writes the mutated copy to a temporary directory — so this only bites if you are experimenting on your own.

__pycache__ directories appeared

Python's bytecode cache. Harmless, ignored by version control, and removable:

find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +

The test runner sets PYTHONDONTWRITEBYTECODE=1 so its own runs leave none.

Security notes

Security notes — Day 073 lab

  • What the lab does. Runs pytest over two small Python files that import nothing, and reads seventeen text files. It makes no network connections at test time, needs no privileges, and writes nothing into the lab directory. The mutation checks copy the implementation into a directory created with mktemp -d and delete it as each check finishes.

  • The single network moment. pip install -r requirements/requirements.txt downloads pytest from the Python Package Index. That is the one command in this lab that reaches the internet, and it is worth treating as such: the version is pinned exactly (pytest==9.1.1) so you get the artefact the captures were made against rather than whatever is newest, and the install goes into a lab-local .venv/ rather than your system Python, so a bad package cannot affect anything outside this directory. Deleting .venv/ undoes it completely.

  • A test suite is executable code you are inviting in. pytest imports and runs every test_*.py file it collects, with your user's permissions. Running someone else's suite is running someone else's program — read a test file from an unfamiliar source before you run it, exactly as you would a script. Everything in this lab is readable in under five minutes, and none of it opens a file, spawns a process, or touches a socket.

  • Writing the test first is a security practice, not only a design one. The refusals in cycles 5, 6 and 7 exist because a scorer that silently totals [11, -1, 99] produces a number that looks like an answer. Stating each refusal as a failing test before the code exists is what forces the input rules to be decided deliberately, at the boundary, instead of being inferred later from whatever the implementation happened to tolerate. Most input validation that is missing in production is missing because nobody ever wrote the sentence "this input must be refused" anywhere.

  • assert in a test is not assert in production. Python's -O flag removes assert statements entirely. That is harmless here, because pytest never runs with -O, but it is the reason you must never use a bare assert as a security check in shipped code. Refusals belong in an explicit raise, which is exactly what ScoringError is.

  • Never let a red test through by weakening it. The most common way a real vulnerability gets shipped is not a missing test — it is a test that failed, and was adjusted until it passed. If cycle 6's refusal test fails, change the implementation. Changing the test to expect twelve pins in a frame makes the suite green and the program wrong, and the green suite then lends its authority to the bug.

  • The recorded history is evidence, and evidence can be forged. The runner verifies that each cycle's captures chain consistently (cycle 5's red must show four earlier tests already green), which makes a fabricated record expensive rather than impossible. Treat that as the honest limit of any process artefact: it raises the cost of lying, it does not make lying impossible. The only real guarantee comes from tests you can re-run yourself, which is why tests/run_tests.sh re-runs everything from source rather than trusting a stored result.

  • No personal data anywhere. The lab handles lists of integers between 0 and 10. Nothing here should ever be pointed at real user data; if you extend the kata with data of your own, keep it out of version control.