Programming with Python › Testing and Code Quality › Day 73
Hands-on lab — Day 73: Test-Driven Development
- ← Back to the Day 73 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-073-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-developmentwhen 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 itat 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 venvand 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
pythonforpython3and.venv\Scripts\pytest.exefor.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. Seerequirements/README.md.bash,sedandcmpfor 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/pytestwould try to collectstarter/test_bowling.pyandexamples/test_bowling.py, two same-named modules with no package around them, and refuse with animport 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 withIndexError: list index out of rangefrom 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
.venv/bin/pytest examples/test_bowling.pyreports9 passedand exits 0.- Every cycle in
starter/cycles.mdhas a RED block containing a real failure and a GREEN block containing a real pass. - 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.
- Each RED block from cycle 2 onward shows the earlier cycles still green —
cycle 5's red must read
1 failed, 4 passed. - Each GREEN block for cycle N reads exactly
N passed. - Cycle 4 has three blocks: red, green, and a refactor run showing the same
4 passedas its green. A refactor that moves the count is not a refactor. - Cycle 8 has a run showing
9 passedand a run against a deliberately broken copy in which both new tests fail. - You answered all four questions at the bottom of
cycles.mdin your own words, before openingexamples/cycles/README.md. bash tests/run_tests.shreports0 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:
- The reference suite passes — pytest exits 0 with nine tests, and six named test ids are actually collected.
- The suite has teeth — the reference implementation is copied to a
directory made with
mktemp -d, broken by a singlesedsubstitution, 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. - 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 say4 passedif cycles 1 to 4 really were written first and really ended green. - 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
- 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. - Do the kata again, from scratch, in twenty minutes. Delete your
starter/work, restore it withgit 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. - Drive the tenth-frame rules properly. The reference treats bonus rolls
with a
bonus_rollscounter. 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. - Add a property. Install Hypothesis into your
.venvand 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. - Break the record on purpose. Edit one capture in
examples/cycles/so its pass count no longer chains — change cycle 5's red to say3 passed— and run the suite. Watch which check catches it, then restore the file withgit checkout -- examples/cycles/. Knowing exactly which check catches a forged history is worth more than being told the record is verified.
Navigation
- 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 -dand delete it as each check finishes. -
The single network moment.
pip install -r requirements/requirements.txtdownloads 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_*.pyfile 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. -
assertin a test is notassertin production. Python's-Oflag removesassertstatements entirely. That is harmless here, because pytest never runs with-O, but it is the reason you must never use a bareassertas a security check in shipped code. Refusals belong in an explicitraise, which is exactly whatScoringErroris. -
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.shre-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.