Programming with PythonTesting and Code Quality › Day 72

Hands-on lab — Day 72: Fixtures, Parametrization, and Test Design

Commands

Setup

cd labs/sections/programming-with-python/day-072-fixtures-parametrization-and-test-design
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version

Run

cd starter && ../.venv/bin/pytest -q
cd starter && ../.venv/bin/pytest --collect-only -q
cd examples && ../.venv/bin/pytest -q
cd examples && ../.venv/bin/pytest --collect-only -q test_sessions.py
cd examples && ../.venv/bin/pytest --collect-only -q -k refuses
cd examples && ../.venv/bin/pytest --collect-only -q -m validation
cd examples && ../.venv/bin/pytest scopes -s -q

Test

bash tests/run_tests.sh

File tree

examples/conftest.py
examples/practice_store.py
examples/pytest.ini
examples/scopes/conftest.py
examples/scopes/test_scopes_first.py
examples/scopes/test_scopes_second.py
examples/test_builtin_fixtures.py
examples/test_sessions.py
examples/test_store.py
expected-output/broken-run.txt
expected-output/FIELDS.md
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/practice_store.py
starter/pytest.ini
starter/refactoring-worksheet.md
starter/test_practice_store.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 072 lab — A Suite That Scales

Lesson

Purpose

You have inherited a test suite. It passes. Thirteen tests, green, no warnings — and eight copies of the same six lines of setup, plus five tests that differ only in one value each.

Nobody would reject that suite in review on correctness grounds. It is still the suite that stops a team from adding tests, because the cost of a new test is now "find the nearest similar one and copy it", and every copy is another place that has to be edited when the constructor gains an argument.

Your job in this lab is to fix it without losing a single assertion, and to measure the difference rather than assert it. You will extract fixtures built on pytest's tmp_path, choose a scope for each one and prove that choice with a run that counts how many times each fixture body executes, collapse five near-identical tests into one parametrized test that still reports as five independent items, build a nine-item cross product from two stacked decorators, register a marker, select subsets with -m and -k, and record a known gap as an xfail instead of deleting the test.

The check that matters most is the one at the end: the harness copies the finished suite, damages one comparison in the code under test, and requires the suite to go red. A suite that stays green against broken code is not a suite. It is decoration with a green tick.

Learning objectives

  • Recognise the two distinct kinds of duplication in a test suite — repeated arrangement and repeated assertion shape — and apply the right tool to each: a fixture for the first, parametrization for the second.
  • Write a @pytest.fixture, request it by parameter name, and explain why pytest resolves fixtures by argument name rather than by import.
  • Build a fixture on top of other fixtures, and read the resulting dependency chain as the replacement for a page of copy-pasted setup.
  • Choose a fixture scope deliberately, and verify the choice by counting how many times each fixture body actually runs in a real captured run.
  • Use yield for teardown, and prove that the teardown half runs even when the test it served has failed.
  • Place fixtures in conftest.py and predict, from the directory layout alone, which test files can see them.
  • Use the built-in tmp_path, capsys and monkeypatch fixtures, and say what each one guarantees that a hand-rolled equivalent does not.
  • Convert repetitive tests to @pytest.mark.parametrize, give the cases readable ids=, and confirm with --collect-only -q that N cases really became N independent test items.
  • Register markers, select with -m and -k, and record a known gap with pytest.param(..., marks=pytest.mark.xfail).
  • Judge when a plain helper function is the better answer than a fixture.

Prerequisites

  • The Day 72 lesson, and Day 71 before it — why we test, arrange-act-assert, reading a pytest report, and proving a test can fail.
  • Day 69: dataclasses, frozen=True, __post_init__ and type hints. The code under test is one of these.
  • Days 64–65: reading and writing files, and the csv module. The store this lab tests is a CSV file with a header row.
  • Day 66: raising exceptions on purpose, which is what the validation tests assert on.
  • Day 43: python3 -m venv. This is the first lab in the course with a third-party dependency, and that is how you install it.
  • A text editor and a terminal.

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 and bash).
  • Windows — use WSL and follow the Linux path. In a native Windows shell the bash harness will not run, but every pytest command in this README works unchanged in PowerShell after activating the virtual environment with .venv\Scripts\activate. All the counts are identical.

Hardware requirements

Any computer that runs Python 3. The suites here finish in well under a second, the largest file the lab writes is a four-line CSV, and pytest itself is a few megabytes. No special memory, disk, GPU, or accelerator.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • pytest version 9.1.1, pinned in requirements/requirements.txt.
  • bash for the outer test harness (preinstalled on macOS and Linux).

See requirements/README.md for the full dependency statement, including what to do if you cannot install anything.

Free and open-source options

Everything here is free and open source. pytest is distributed under the MIT licence; Python under the Python Software Foundation Licence. There is no account to create, no key to obtain, and no paid tier involved at any point.

The lesson's Alternatives section covers the other ways to do what this lab does — unittest with setUp/tearDown (in the standard library, so already installed), plain factory functions (no dependency at all), factory_boy with pytest-factoryboy, and Hypothesis for generating cases instead of listing them. All of those are free and open source too. None is needed here.

Installation

The install is the only step in this lab that uses the network. Run it once:

cd labs/sections/programming-with-python/day-072-fixtures-parametrization-and-test-design
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version

That last line should print pytest 9.1.1. Everything after this point runs fully offline.

Throughout this README, pytest means .venv/bin/pytest (or ../.venv/bin/pytest when you are inside starter/ or examples/). If you prefer, activate the environment once with source .venv/bin/activate and type pytest plainly.

File structure

day-072-fixtures-parametrization-and-test-design/
├── README.md                        ← you are here
├── metadata.yml                     ← machine-readable lab metadata
├── starter/
│   ├── practice_store.py            ← the code under test (complete, working)
│   ├── test_practice_store.py       ← the suite you inherited: 13 tests, 8 copies of the setup
│   ├── pytest.ini                   ← marks the root of the starter suite
│   └── refactoring-worksheet.md     ← YOUR work: exercises 0-7 and the measurement table
├── examples/
│   ├── practice_store.py            ← identical copy of the code under test
│   ├── conftest.py                  ← the five shared fixtures
│   ├── pytest.ini                   ← registered markers + --strict-markers
│   ├── test_sessions.py             ← the parametrized value rules (21 items from 4 functions)
│   ├── test_store.py                ← store behaviour, entirely fixture-driven (10 items)
│   ├── test_builtin_fixtures.py     ← tmp_path, capsys, monkeypatch, and one honest helper
│   └── scopes/
│       ├── conftest.py              ← a second conftest layer, visible only here
│       ├── test_scopes_first.py     ← three tests requesting all three scopes
│       └── test_scopes_second.py    ← a second module, plus a test that fails on purpose
├── tests/
│   └── run_tests.sh                 ← the outer harness; exits 0 only if all 38 checks pass
├── expected-output/
│   ├── sample-run.txt               ← real captured runs of both suites
│   ├── test-run.txt                 ← real captured run of the harness
│   ├── broken-run.txt               ← what the suite does when the code is damaged
│   └── FIELDS.md                    ← every number that is required, and why
├── requirements/
│   ├── requirements.txt             ← pytest==9.1.1
│   └── README.md                    ← dependency statement
├── troubleshooting.md
└── security.md

The lab writes no files into your working directory. Everything the tests create goes into a directory pytest makes for them under the system temporary area, and the harness cleans up after itself.

How to run

From this directory, after the install:

## 1. Meet the suite you inherited. It passes. Count the duplication.
cd starter
../.venv/bin/pytest -q
../.venv/bin/pytest --collect-only -q | tail -2
grep -c 'store = PracticeStore(path)' test_practice_store.py
cd ..

## 2. Read the worksheet. Exercises 0 to 7, in order.
cat starter/refactoring-worksheet.md

## 3. Do the work in starter/. Create conftest.py, extract the fixtures,
##    parametrize the five rejection tests, register a marker.

## 4. See the finished reference and how many items it collects.
cd examples
../.venv/bin/pytest -q
../.venv/bin/pytest --collect-only -q | tail -2

## 5. The check that proves parametrization expanded: read the ids.
../.venv/bin/pytest --collect-only -q test_sessions.py

## 6. Select subsets two ways — by marker, and by substring.
../.venv/bin/pytest --collect-only -q -m validation | tail -2
../.venv/bin/pytest --collect-only -q -k refuses | tail -2

## 7. Watch fixture scope happen. -s stops pytest swallowing print output.
../.venv/bin/pytest scopes -s -q
cd ..

## 8. Run the outer harness, which checks all of the above and more.
bash tests/run_tests.sh

What the commands do

  • pytest -q in starter/ — runs the inherited suite. 13 passed. Quiet mode prints one character per test and the summary line; that is all you need until something breaks.
  • pytest --collect-only -q — collects tests without running any of them and lists their ids. This is the single most useful pytest flag for today: it answers "what does pytest think my suite contains?", which is exactly the question parametrization makes non-obvious.
  • grep -c 'store = PracticeStore(path)' test_practice_store.py — counts the copies of the setup. It prints 8. That number is the lab's premise, and the first row of your measurement table.
  • pytest -q in examples/ — runs the refactored suite: 39 passed, 2 xfailed. The two xfailed are deliberate and explained below.
  • pytest --collect-only -q test_sessions.py — shows the expansion. Four test functions become twenty-one items, each with the readable id its ids= argument gave it. Compare [minutes-zero] with what you would get without ids=: [minutes4].
  • pytest --collect-only -q -m validation — selects by marker, a label attached to a test in code. 21/41 tests collected (20 deselected).
  • pytest --collect-only -q -k refuses — selects by substring of the test id, parameter ids included. 11/41 tests collected (30 deselected). Note that -k needs no code changes at all, which makes it the flag you reach for while debugging and -m the one you put in a configuration file.
  • pytest scopes -s -q — the scope demonstration. Each fixture prints when its body runs, and -s (short for --capture=no) stops pytest from swallowing that output. Across two modules and five tests you will see the session fixture run once, the module fixture twice, and the function fixture four times, each with a matching teardown.
  • bash tests/run_tests.sh — 38 checks covering every claim above, plus the conftest-scoping rule, --strict-markers, and the deliberate break.

Expected output

Full captures are in expected-output/. The two most important extracts:

Parametrization expanded — one function, six independently reported cases:

$ cd examples && pytest --collect-only -q test_sessions.py
test_sessions.py::test_accepts_a_valid_session[shortest-allowed]
test_sessions.py::test_accepts_a_valid_session[typical]
test_sessions.py::test_accepts_a_valid_session[longest-allowed]
test_sessions.py::test_refuses_a_bad_value[ref-without-prefix]
test_sessions.py::test_refuses_a_bad_value[ref-too-short]
test_sessions.py::test_refuses_a_bad_value[topic-not-lower-case]
test_sessions.py::test_refuses_a_bad_value[topic-padded-with-spaces]
test_sessions.py::test_refuses_a_bad_value[minutes-zero]
test_sessions.py::test_refuses_a_bad_value[minutes-over-the-cap]
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-python]
...
21 tests collected in 0.00s

Scope is a number of executions, not a comment:

$ cd examples && pytest scopes -s -q

    [session] body ran

    [module ] body ran

    [funct  ] body ran
.
    [funct  ] teardown ran
...
    [funct  ] body ran
x
    [funct  ] teardown ran

    [module ] teardown ran

    [session] teardown ran

4 passed, 1 xfailed in 0.01s

Read the last few lines carefully. The x is the test that fails on purpose, and the [funct ] teardown ran line comes after it. That is the guarantee yield gives you and a try/finally in every test does not.

expected-output/FIELDS.md lists every number that is required on every platform, and the one field (elapsed time) that is allowed to differ.

Validation steps

  1. cd starter && ../.venv/bin/pytest -q reports 13 passed before you start and 13 passed after your refactor. Same assertions, fewer lines.
  2. grep -c 'store = PracticeStore(path)' starter/test_practice_store.py prints 8 before your refactor and 0 after it.
  3. cd examples && ../.venv/bin/pytest --collect-only -q | tail -2 reports exactly 41 tests collected.
  4. ../.venv/bin/pytest --collect-only -q -k refuses | tail -2 reports exactly 11/41 tests collected (30 deselected).
  5. ../.venv/bin/pytest --collect-only -q -m validation | tail -2 reports exactly 21/41 tests collected (20 deselected).
  6. In pytest scopes -s -q, count the lines yourself: grep -c '\[funct \] body ran' gives 4 and grep -c '\[session\] body ran' gives 1.
  7. Every test id in pytest --collect-only -q test_sessions.py ends in a name a human chose. If you see [ref0], an ids= list is missing.
  8. Break it on purpose and watch it go red — the most important check in the lab, and the one you should run by hand at least once: copy examples/ somewhere temporary, change if self.minutes <= 0: to if self.minutes < 0: in the copy, and run the suite there. You must see 1 failed, 38 passed, 2 xfailed and the failing case named as test_refuses_a_bad_value[minutes-zero].
  9. Every row of starter/refactoring-worksheet.md is filled in, including the measurement table and the closing design question, in sentences.
  10. bash tests/run_tests.sh reports 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

Expected final line: 38 checks, 0 failure(s). and exit code 0. A full capture is in expected-output/test-run.txt.

On the authoring machine the harness is run with an explicit pytest path; if you installed the lab-local virtual environment above, it finds .venv/bin/pytest on its own. To point it somewhere else:

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

The suite is worth reading before you run it, because its nine sections are the lesson's claims turned into assertions. Three deserve attention:

  • Section 2 asserts exact collected counts. This is the only check that can tell the difference between "six independent test items" and "one test with a loop in it", and it is why --collect-only is in this lab at all.
  • Section 6 creates a test file that asks for a fixture defined in examples/scopes/conftest.py and requires pytest to refuse it. Conftest visibility flows downward only, and this proves it rather than asserting it.
  • Section 8 copies examples/ into a temporary directory, changes one comparison with sed, and requires the suite to fail — with exactly one failure, naming exactly one parametrized case. The copy is deleted immediately; your files are never touched.

Cleanup

rm -rf .venv                 # remove the virtual environment
git checkout -- starter/     # optional: reset your work

The tests write nothing into this directory. tmp_path directories live under the system temporary area and pytest removes all but the last few runs automatically; the harness deletes its own mktemp -d directories as each check finishes.

Troubleshooting

See troubleshooting.md for the full list: pytest not found, fixture 'empty_store' not found and its three usual causes, the --strict-markers error on a mistyped marker, a parametrize that did not expand, ids that look like [ref0], a [audit] print you cannot see without -s, ModuleNotFoundError: No module named 'practice_store', and the classic "passes alone, fails in the suite" — which is shared mutable state, and is worth understanding rather than working around.

Security notes

See security.md. Short version: the only networked moment in this lab is pip install, and it is worth treating as a real trust decision — install into a virtual environment, pin the version, read the package name before you run the command. tmp_path is a security feature, not a convenience: a test that writes to a hard-coded path can clobber real data. monkeypatch undoes itself, which is containment rather than tidiness. Never put a credential in a test or a conftest.py, and remember that a conftest.py is executable code that runs automatically before any test does.

Extension exercises

  1. Delete a fixture on purpose. Pick the fixture used by the fewest tests, inline it as a plain helper function, and read both versions. Which one lets a stranger see what the test sets up without opening another file? Write down your answer — "always use a fixture" is not a design position, it is a habit.
  2. Make a scope wrong and watch it bite. Change empty_store in examples/conftest.py to scope="module" and run pytest -q test_store.py. Tests will start failing, and the failures will depend on order. Then explain, in one sentence, why sample_sessions is safe at session scope while empty_store is not. (The answer is one word, and the word is not "speed".)
  3. Parametrize the store tests. test_total_minutes_adds_the_right_sessions is already parametrized over three topics. Add a fourth case for a topic that is not in the log at all, predict the expected value before you run it, and check the collected count went from 41 to 42.
  4. Add an indirect parametrization. Look up indirect=True in the pytest documentation and use it to parametrize the loaded_store fixture itself, so the same test runs against an empty store and a full one. Then decide whether the result is clearer than two separate fixtures. Often it is not, and knowing when a feature is not worth it is the point of the day.
  5. Turn a marker into a workflow. Add -m "not slow" to addopts in a second configuration file, so the default local run skips the file-reading test and a full run needs an explicit -m slow. Then write two sentences on what you have just traded away, because a test that never runs by default is a test that will eventually stop working without anyone noticing.
  • Previous day: Day 71 — Why Test, and pytest Basics (labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/).
  • Next day: Day 73 — Test-Driven Development (labs/sections/programming-with-python/day-073-test-driven-development/), which takes today's suite design and inverts the order: the test first, the code second.
  • Week 11 project: the Tested Utility Library (labs/sections/programming-with-python/projects/week-11/). Every fixture and every parametrized case you write there starts from what you built here.

Expected output

FIELDS.md

# Expected output — Day 072 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, and every file it
writes goes into a temporary directory pytest creates, so the same commands
produce the same numbers on any machine with Python 3 and pytest 9.

Only the elapsed times vary — `in 0.03s` on one machine may be `in 0.09s` on
another. Ignore them. Every other number below is required.

## Files

- `sample-run.txt` — the starter suite, then the refactored suite, then the
  collection listing that proves parametrization expanded, then the `-k` and
  `-m` selections, then the fixture-scope demonstration run with `-s`, then a
  single test showing `yield`-fixture teardown output, and finally the
  built-in `tmp_path`, `capsys` and `monkeypatch` demonstrations.
- `test-run.txt` — a full run of `bash tests/run_tests.sh`: 38 checks, 0
  failures, exit 0.
- `broken-run.txt` — what the refactored suite does when one comparison in
  `practice_store.py` is damaged on purpose. This is the capture worth
  studying: the suite goes red, and it names the exact parametrized case.

## Required numbers

| Command (from the lab directory) | Required result |
| --- | --- |
| `cd starter && pytest -q` | `13 passed` |
| `cd starter && pytest --collect-only -q \| tail -2` | `13 tests collected` |
| `grep -c 'store = PracticeStore(path)' starter/test_practice_store.py` | `8` |
| `cd examples && pytest -q` | `39 passed, 2 xfailed` |
| `cd examples && pytest --collect-only -q \| tail -2` | `41 tests collected` |
| `cd examples && pytest --collect-only -q -k refuses \| tail -2` | `11/41 tests collected (30 deselected)` |
| `cd examples && pytest --collect-only -q -m validation \| tail -2` | `21/41 tests collected (20 deselected)` |
| `cd examples && pytest --collect-only -q -m "not slow" \| tail -2` | `40/41 tests collected (1 deselected)` |
| `cd examples && pytest scopes -s -q` | `4 passed, 1 xfailed` |
| `bash tests/run_tests.sh` | `38 checks, 0 failure(s).` and exit 0 |

Read the second and fifth rows together. The starter has 13 hand-written test
functions and pytest collects 13 items — one to one, because nothing is
parametrized. The refactored suite has **22** hand-written test functions
(4 in `test_sessions.py`, 8 in `test_store.py`, 5 in
`test_builtin_fixtures.py`, 5 in `scopes/`) and pytest collects **41** items,
because four of those functions are parametrized. That
gap is the entire argument for parametrization: more cases without more code,
each one reported by name.

Count it precisely if you want to see where the 41 comes from:
3 + 6 + 9 + 3 = 21 items from `test_sessions.py`, plus 7 + 3 = 10 from
`test_store.py`, plus 5 from `test_builtin_fixtures.py`, plus 5 from
`scopes/`.

## Required fixture-scope behaviour

`cd examples && pytest scopes -s -q` must show exactly these counts across the
whole run of the `scopes/` directory (two modules, five tests):

| Fixture | Scope | Times its body runs | Why |
| --- | --- | --- | --- |
| `session_scoped` | `session` | 1 | One pytest process, one construction |
| `module_scoped` | `module` | 2 | `test_scopes_first.py` and `test_scopes_second.py` |
| `function_scoped` | `function` | 4 | The four tests that request it |

The teardown half of `function_scoped` must also appear **4** times. The
fourth of those four tests fails on purpose (it is marked `xfail`), so a
fourth teardown line is the proof that teardown after `yield` runs whether the
test passed or failed.

## Required test ids

`cd examples && pytest --collect-only -q test_sessions.py` must list ids that
a human chose, not ids pytest invented:

```text
test_sessions.py::test_refuses_a_bad_value[ref-without-prefix]
test_sessions.py::test_refuses_a_bad_value[ref-too-short]
test_sessions.py::test_refuses_a_bad_value[topic-not-lower-case]
test_sessions.py::test_refuses_a_bad_value[topic-padded-with-spaces]
test_sessions.py::test_refuses_a_bad_value[minutes-zero]
test_sessions.py::test_refuses_a_bad_value[minutes-over-the-cap]
```

If you see `[ref0]`, `[ref1]`, `[ref2]` instead, the `ids=` argument is
missing. The suite still works; the failure report just stops telling you
anything useful.

The stacked decorators must produce the nine-item cross product, with the
**bottom** decorator's parameter appearing first in the id:

```text
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-python]
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-linear-algebra]
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-statistics]
test_sessions.py::test_every_topic_accepts_every_legal_duration[45-python]
...
test_sessions.py::test_every_topic_accepts_every_legal_duration[600-statistics]
```

## Required failure behaviour

Change `if self.minutes <= 0:` to `if self.minutes < 0:` in
`examples/practice_store.py` and the suite must go red with exactly one
failure, naming the case:

```text
FAILED test_sessions.py::test_refuses_a_bad_value[minutes-zero] - Failed: DID...
1 failed, 38 passed, 2 xfailed in 0.04s
```

Two things in that line are the day's argument in miniature. First, the suite
noticed — a suite that stays green against broken code is worthless. Second,
`1 failed, 38 passed`: the other five cases of the same function still ran and
still passed. Had those six cases been a `for` loop inside one test, the loop
would have stopped at the first failure and reported one broken test with no
detail about the rest.

## Platform differences

- **macOS and Linux** — identical output. `tmp_path` lands under
  `/private/var/folders/...` on macOS and `/tmp/pytest-of-<user>/...` on most
  Linux systems; nothing in this lab prints those paths, so no capture depends
  on them.
- **Windows** — run the commands in WSL and follow the Linux path. In a native
  Windows shell, `bash tests/run_tests.sh` will not run (it is a bash script),
  but every `pytest` command in the tables above works unchanged in PowerShell
  once you activate the virtual environment with `.venv\Scripts\activate`. The
  counts are the same.
- The elapsed time on every summary line will differ from the captures. That
  is the only field that is allowed to differ.

broken-run.txt

$ sed -e "s|if self.minutes <= 0:|if self.minutes < 0:|" examples/practice_store.py > <copy>/practice_store.py
$ cd <copy> && pytest -q
>       with pytest.raises(InvalidSession):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE InvalidSession

test_sessions.py:49: Failed
=========================== short test summary info ============================
FAILED test_sessions.py::test_refuses_a_bad_value[minutes-zero] - Failed: DID...
1 failed, 38 passed, 2 xfailed in 0.04s

sample-run.txt

$ cd starter && pytest -q
.............                                                            [100%]
13 passed in 0.02s

$ cd starter && pytest --collect-only -q | tail -2

13 tests collected in 0.01s

$ grep -c "store = PracticeStore(path)" starter/test_practice_store.py
8

$ cd examples && pytest -q
....x.........................x..........                                [100%]
39 passed, 2 xfailed in 0.04s

$ cd examples && pytest --collect-only -q test_sessions.py
test_sessions.py::test_accepts_a_valid_session[shortest-allowed]
test_sessions.py::test_accepts_a_valid_session[typical]
test_sessions.py::test_accepts_a_valid_session[longest-allowed]
test_sessions.py::test_refuses_a_bad_value[ref-without-prefix]
test_sessions.py::test_refuses_a_bad_value[ref-too-short]
test_sessions.py::test_refuses_a_bad_value[topic-not-lower-case]
test_sessions.py::test_refuses_a_bad_value[topic-padded-with-spaces]
test_sessions.py::test_refuses_a_bad_value[minutes-zero]
test_sessions.py::test_refuses_a_bad_value[minutes-over-the-cap]
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-python]
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-linear-algebra]
test_sessions.py::test_every_topic_accepts_every_legal_duration[1-statistics]
test_sessions.py::test_every_topic_accepts_every_legal_duration[45-python]
test_sessions.py::test_every_topic_accepts_every_legal_duration[45-linear-algebra]
test_sessions.py::test_every_topic_accepts_every_legal_duration[45-statistics]
test_sessions.py::test_every_topic_accepts_every_legal_duration[600-python]
test_sessions.py::test_every_topic_accepts_every_legal_duration[600-linear-algebra]
test_sessions.py::test_every_topic_accepts_every_legal_duration[600-statistics]
test_sessions.py::test_refuses_a_date_outside_the_practice_log[today-ish]
test_sessions.py::test_refuses_a_date_outside_the_practice_log[long-ago]
test_sessions.py::test_refuses_a_date_outside_the_practice_log[far-future]

21 tests collected in 0.00s

$ cd examples && pytest --collect-only -q -k refuses | tail -2

11/41 tests collected (30 deselected) in 0.01s

$ cd examples && pytest --collect-only -q -m validation | tail -2

21/41 tests collected (20 deselected) in 0.01s

$ cd examples && pytest --collect-only -q -m "not slow" | tail -2

40/41 tests collected (1 deselected) in 0.01s

$ cd examples && pytest scopes -s -q

    [session] body ran

    [module ] body ran

    [funct  ] body ran
.
    [funct  ] teardown ran

    [funct  ] body ran
.
    [funct  ] teardown ran

    [funct  ] body ran
.
    [funct  ] teardown ran

    [module ] teardown ran

    [module ] body ran
.
    [funct  ] body ran
x
    [funct  ] teardown ran

    [module ] teardown ran

    [session] teardown ran

4 passed, 1 xfailed in 0.01s

$ cd examples && pytest -s -q test_store.py::test_teardown_reports_the_final_size
.[audit] sessions before: 3, after: 4

1 passed in 0.01s

$ cd examples && pytest -q test_builtin_fixtures.py
.....                                                                    [100%]
5 passed in 0.01s

test-run.txt

Day 072 lab checks

pytest: pytest 9.1.1

1. The refactored suite
  ok: examples/ suite passes and exits 0
  ok: examples/ run summary is '39 passed, 2 xfailed'
  ok: starter/ suite passes as shipped (the refactor must not break it)

2. Parametrization expanded — exact collected counts
  ok: examples/ collects the full expanded suite (41)
  ok: starter/ collects its hand-written tests (13)
  ok: test_refuses_a_bad_value expands to six items (6)
  ok: two stacked parametrize decorators give the 3x3 cross product (9)
  ok: ids= produced readable test ids, not [ref0]
  ok: no generated placeholder ids remain

3. Selection: -k expressions and registered markers
  ok: -k refuses selects exactly the eleven refusal items (11)
  ok: -m validation selects exactly the value-rule module (21)
  ok: -m 'not slow' deselects exactly the one file-reading test (40)

4. Fixture scope is an observable number of executions
  ok: session-scoped fixture body ran once for the whole run (1)
  ok: module-scoped fixture body ran once per module (two modules) (2)
  ok: function-scoped fixture body ran once per test (four tests) (4)
  ok: every function-scoped setup was matched by a teardown (4)
  ok: the deliberately failing test is reported as xfailed
  ok: yield-fixture teardown observed the test's changes

5. The built-in fixtures behave as documented
  ok: the built-in fixture demonstrations all pass (5)
  ok: monkeypatch's change did not survive the test that made it
  ok: a test demonstrates the built-in tmp_path fixture
  ok: a test demonstrates the built-in capsys fixture
  ok: a test demonstrates the built-in monkeypatch fixture

6. conftest.py discovery is directory-scoped
  ok: a fixture from scopes/conftest.py is unusable outside scopes/
  ok: pytest names the missing fixture in its error

7. --strict-markers turns a typo into an error
  ok: an unregistered marker is rejected under --strict-markers

8. The suite fails when the implementation is broken
  ok: the deliberate break was applied to the copy
  ok: broken implementation makes the suite fail
  ok: the failure names the exact parametrized case: [minutes-zero]
  ok: exactly one of the six parametrized cases failed

9. The lab's own premise holds
  ok: starter/ really does copy-paste its setup (8)
  ok: examples/test_store.py constructs no store of its own
  ok: conftest.py defines the store_path fixture
  ok: conftest.py defines the empty_store fixture
  ok: conftest.py defines the loaded_store fixture
  ok: conftest.py defines the audited_store fixture
  ok: conftest.py defines the sample_sessions fixture
  ok: the store fixture is built on the built-in tmp_path fixture

38 checks, 0 failure(s).

Source files

examples/conftest.py (3187 bytes)
"""Shared fixtures for every test module in this directory and below.

`conftest.py` is not imported by anything. pytest finds it by *location*: when
it collects a test file it walks up the directory tree collecting every
`conftest.py` it passes, and the fixtures defined in them become available to
that file by name. A fixture defined here is visible to `test_sessions.py`,
to `test_store.py`, and to everything under `scopes/`. A fixture defined in
`scopes/conftest.py` is visible only inside `scopes/`.
"""

from datetime import date

import pytest

from practice_store import PracticeStore, Session

# ---------------------------------------------------------------------------
# Data. Frozen dataclasses cannot be mutated, so one tuple of them is safe to
# share across the whole run — which is why this fixture may be session-scoped
# without any risk of one test corrupting another's data.
# ---------------------------------------------------------------------------


@pytest.fixture(scope="session")
def sample_sessions():
    """Three immutable sessions across two topics: 45 + 30 + 60 = 135 minutes."""
    return (
        Session("S-001", date(2026, 5, 4), "python", 45),
        Session("S-002", date(2026, 5, 5), "linear-algebra", 30),
        Session("S-003", date(2026, 5, 6), "python", 60),
    )


# ---------------------------------------------------------------------------
# Files and stores. These are function-scoped on purpose: a store is mutable
# state on disk, and sharing mutable state between tests is how a suite starts
# passing or failing depending on the order it runs in.
# ---------------------------------------------------------------------------


@pytest.fixture
def store_path(tmp_path):
    """A path inside pytest's own per-test temporary directory.

    `tmp_path` is a built-in fixture. pytest creates a fresh directory for
    every test that asks for one, hands it over as a `pathlib.Path`, and keeps
    the last few runs on disk so a failure can be inspected afterwards.
    """
    return tmp_path / "practice.csv"


@pytest.fixture
def empty_store(store_path):
    """An initialised store with a header row and no sessions."""
    return PracticeStore(store_path).initialise()


@pytest.fixture
def loaded_store(empty_store, sample_sessions):
    """A store holding the three sample sessions.

    Note what is happening: a fixture requests two other fixtures by name, and
    pytest builds the whole chain before the test body runs. This is the
    dependency graph that replaces a page of copy-pasted setup.
    """
    for session in sample_sessions:
        empty_store.add(session)
    return empty_store


@pytest.fixture
def audited_store(loaded_store):
    """A loaded store that reports what it looked like on the way out.

    Everything before `yield` is setup; everything after it is teardown, and
    pytest runs the teardown whether the test passed, failed, or raised. That
    guarantee is the reason `yield` fixtures replaced hand-written cleanup.
    """
    before = len(loaded_store.all())
    yield loaded_store
    after = len(loaded_store.all())
    print(f"[audit] sessions before: {before}, after: {after}")
examples/practice_store.py (5816 bytes)
"""A tiny CSV-backed store of practice sessions — the code under test.

This module is deliberately small and deliberately ordinary. It is the kind of
thing you wrote on Days 64 and 65: a dataclass with a few rules, and a class
that reads and writes one CSV file. Nothing here knows that pytest exists.

The interesting part of this lab is not this file. It is the shape of the test
suite that surrounds it.
"""

from __future__ import annotations

import csv
from dataclasses import dataclass
from datetime import date
from pathlib import Path

FIELDNAMES = ["ref", "logged_on", "topic", "minutes"]

#: The longest single session the log will accept, in minutes.
MAX_MINUTES = 600


class StoreError(Exception):
    """Any rule of the practice-log domain that was refused."""


class InvalidSession(StoreError):
    """A session was built from values the rules forbid."""


class DuplicateRef(StoreError):
    """A reference already present in the store was added again."""


class UnknownRef(StoreError):
    """A reference the store has never seen was asked for."""


@dataclass(frozen=True)
class Session:
    """One practice session: a reference, a date, a topic, and a duration."""

    ref: str
    logged_on: date
    topic: str
    minutes: int

    def __post_init__(self) -> None:
        if not isinstance(self.ref, str) or not _looks_like_ref(self.ref):
            raise InvalidSession(f"reference must look like S-001, got {self.ref!r}")
        if not isinstance(self.logged_on, date):
            raise InvalidSession(f"logged_on must be a date, got {self.logged_on!r}")
        if not self.topic or self.topic != self.topic.strip().lower():
            raise InvalidSession(
                f"topic must be non-empty and lower case with no padding, got {self.topic!r}"
            )
        if isinstance(self.minutes, bool) or not isinstance(self.minutes, int):
            raise InvalidSession(f"minutes must be a whole number, got {self.minutes!r}")
        if self.minutes <= 0:
            raise InvalidSession(f"minutes must be positive, got {self.minutes}")
        if self.minutes > MAX_MINUTES:
            raise InvalidSession(
                f"minutes must be at most {MAX_MINUTES}, got {self.minutes}"
            )

    def as_row(self) -> dict[str, str]:
        """Render this session as the dictionary one CSV row holds."""
        return {
            "ref": self.ref,
            "logged_on": self.logged_on.isoformat(),
            "topic": self.topic,
            "minutes": str(self.minutes),
        }

    @classmethod
    def from_row(cls, row: dict[str, str]) -> "Session":
        """Rebuild a session from one CSV row, through the same rules."""
        try:
            minutes = int(row["minutes"])
        except (KeyError, TypeError, ValueError) as exc:
            raise InvalidSession(f"minutes column is not a whole number: {exc}") from exc
        try:
            logged_on = date.fromisoformat(row["logged_on"])
        except (KeyError, TypeError, ValueError) as exc:
            raise InvalidSession(f"logged_on column is not an ISO date: {exc}") from exc
        return cls(
            ref=row.get("ref", ""),
            logged_on=logged_on,
            topic=row.get("topic", ""),
            minutes=minutes,
        )


def _looks_like_ref(ref: str) -> bool:
    """True when ref is the letter S, a hyphen, and exactly three digits."""
    return (
        len(ref) == 5
        and ref[0] == "S"
        and ref[1] == "-"
        and ref[2:].isdigit()
    )


class PracticeStore:
    """A practice log kept in one CSV file.

    Every read goes to the file, so the store never holds a stale copy. That is
    a deliberate design choice for this lab: it makes the store slow enough
    that the cost of rebuilding it in every test is visible, which is exactly
    the pressure that makes fixtures and their scopes worth learning.
    """

    def __init__(self, path: Path | str) -> None:
        self.path = Path(path)

    def initialise(self) -> "PracticeStore":
        """Create the file with its header row, replacing anything there."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("w", newline="", encoding="utf-8") as handle:
            csv.DictWriter(handle, fieldnames=FIELDNAMES).writeheader()
        return self

    def add(self, session: Session) -> Session:
        """Append one session, refusing a reference the store already holds."""
        if any(existing.ref == session.ref for existing in self.all()):
            raise DuplicateRef(f"{session.ref} is already in the log")
        with self.path.open("a", newline="", encoding="utf-8") as handle:
            csv.DictWriter(handle, fieldnames=FIELDNAMES).writerow(session.as_row())
        return session

    def all(self) -> list[Session]:
        """Every session in the file, in the order it was written."""
        if not self.path.exists():
            return []
        with self.path.open(newline="", encoding="utf-8") as handle:
            return [Session.from_row(row) for row in csv.DictReader(handle)]

    def find(self, ref: str) -> Session:
        """The session with this reference, or UnknownRef."""
        for session in self.all():
            if session.ref == ref:
                return session
        raise UnknownRef(f"{ref} is not in the log")

    def total_minutes(self, topic: str | None = None) -> int:
        """Total minutes practised, optionally narrowed to one topic."""
        return sum(
            session.minutes
            for session in self.all()
            if topic is None or session.topic == topic
        )

    def topics(self) -> list[str]:
        """Every distinct topic in the log, in alphabetical order."""
        return sorted({session.topic for session in self.all()})
examples/pytest.ini (354 bytes)
[pytest]
# Registering markers turns a typo into an error instead of a silent no-op.
# With `--strict-markers`, `@pytest.mark.slwo` fails the run rather than
# quietly marking nothing.
addopts = --strict-markers
markers =
    validation: a test of the Session value rules — fast, no files touched
    slow: a test that reads the CSV file back off disk
examples/scopes/conftest.py (1010 bytes)
"""A second conftest layer, visible only inside this directory.

The fixtures below print a line every time their body runs, so a run with
`-s` shows exactly how often each scope is created. That is the whole demo:
scope is not documentation, it is an observable number of executions.
"""

import pytest


@pytest.fixture(scope="session")
def session_scoped():
    """Built once for the entire pytest run, however many tests ask for it."""
    print("\n    [session] body ran")
    yield "session-value"
    print("\n    [session] teardown ran")


@pytest.fixture(scope="module")
def module_scoped():
    """Built once per test module that asks for it."""
    print("\n    [module ] body ran")
    yield "module-value"
    print("\n    [module ] teardown ran")


@pytest.fixture(scope="function")
def function_scoped():
    """Built again for every single test — the default, and the safe default."""
    print("\n    [funct  ] body ran")
    yield "function-value"
    print("\n    [funct  ] teardown ran")
examples/scopes/test_scopes_first.py (517 bytes)
"""Three tests, each requesting all three scopes. Run this with -s to watch."""


def test_first(session_scoped, module_scoped, function_scoped):
    assert (session_scoped, module_scoped, function_scoped) == (
        "session-value",
        "module-value",
        "function-value",
    )


def test_second(session_scoped, module_scoped, function_scoped):
    assert session_scoped == "session-value"


def test_third(session_scoped, module_scoped, function_scoped):
    assert function_scoped == "function-value"
examples/scopes/test_scopes_second.py (695 bytes)
"""A second module in the same directory, which is what makes the demo work.

The session fixture does NOT run again here — it was already built. The module
fixture DOES, because this is a different module. And the deliberately failing
test proves the thing you most need to trust about `yield` fixtures: teardown
runs anyway.
"""

import pytest


def test_session_scope_is_not_rebuilt(session_scoped, module_scoped):
    assert session_scoped == "session-value"


@pytest.mark.xfail(strict=True, reason="fails on purpose, to show teardown still runs")
def test_teardown_runs_even_when_the_test_fails(function_scoped):
    assert function_scoped == "this is not the value, so this test fails"
examples/test_builtin_fixtures.py (2481 bytes)
"""The three built-in fixtures worth knowing on day one.

You never define these. pytest ships them, and they are available by name in
any test, in any file, with no `conftest.py` involved. `pytest --fixtures`
lists every fixture visible to a given file, built-ins included.
"""

import os

from practice_store import PracticeStore, Session


def config_dir():
    """Ordinary application code that reads the environment."""
    return os.environ.get("PRACTICE_HOME", "/etc/practice")


def describe(store):
    """Ordinary application code that prints."""
    print(f"{len(store.all())} sessions, {store.total_minutes()} minutes")


def test_tmp_path_is_a_fresh_empty_directory(tmp_path):
    """`tmp_path` is a pathlib.Path to a directory nobody else is using."""
    assert list(tmp_path.iterdir()) == []
    store = PracticeStore(tmp_path / "practice.csv").initialise()
    assert store.path.exists()
    assert store.all() == []


def test_capsys_captures_what_was_printed(capsys, loaded_store):
    """`capsys` lets you assert on output without changing the code to return it."""
    describe(loaded_store)
    captured = capsys.readouterr()
    assert captured.out == "3 sessions, 135 minutes\n"
    assert captured.err == ""


def test_monkeypatch_sets_an_environment_variable(monkeypatch, tmp_path):
    """`monkeypatch` changes the world for one test and undoes it afterwards."""
    monkeypatch.setenv("PRACTICE_HOME", str(tmp_path))
    assert config_dir() == str(tmp_path)


def test_the_environment_is_back_to_normal():
    """Proof that the previous test's change did not survive it.

    This is the whole reason to use `monkeypatch` instead of assigning to
    `os.environ` yourself: the undo is not something you have to remember.
    """
    assert config_dir() == "/etc/practice"


def test_a_fixture_and_a_helper_can_coexist(empty_store):
    """A helper function is fine, and often better than a fixture.

    `three_sessions` below is used by exactly one test. Making it a fixture
    would put its definition in another file and its invocation in a parameter
    name — indirection with nothing to show for it.
    """

    def three_sessions(first_number):
        from datetime import date

        return [
            Session(f"S-{first_number + n:03d}", date(2026, 8, n), "pytest", n * 10)
            for n in (1, 2, 3)
        ]

    for session in three_sessions(200):
        empty_store.add(session)
    assert empty_store.total_minutes() == 60
examples/test_sessions.py (2867 bytes)
"""The rules of one Session, expressed as parametrized tests.

Every test in this module is about a value, not about a file, so not one of
them asks for a store or a path. That is the first sign of a suite that has
been designed rather than accumulated: the cheap tests stay cheap.
"""

from datetime import date, timedelta

import pytest

from practice_store import MAX_MINUTES, InvalidSession, Session

# Applies the `validation` marker to every test in this module at once, so
# `pytest -m validation` selects the whole file without repeating a decorator.
pytestmark = pytest.mark.validation


@pytest.mark.parametrize(
    "minutes",
    [1, 45, MAX_MINUTES],
    ids=["shortest-allowed", "typical", "longest-allowed"],
)
def test_accepts_a_valid_session(minutes):
    session = Session("S-001", date(2026, 5, 4), "python", minutes)
    assert session.minutes == minutes


@pytest.mark.parametrize(
    ("ref", "topic", "minutes"),
    [
        ("001", "python", 45),
        ("S-1", "python", 45),
        ("S-001", "Python", 45),
        ("S-001", " python ", 45),
        ("S-001", "python", 0),
        ("S-001", "python", MAX_MINUTES + 1),
    ],
    ids=[
        "ref-without-prefix",
        "ref-too-short",
        "topic-not-lower-case",
        "topic-padded-with-spaces",
        "minutes-zero",
        "minutes-over-the-cap",
    ],
)
def test_refuses_a_bad_value(ref, topic, minutes):
    with pytest.raises(InvalidSession):
        Session(ref, date(2026, 5, 4), topic, minutes)


# Two stacked decorators produce the cross product: every topic is combined
# with every duration, so three and three become nine independent test items.
@pytest.mark.parametrize("topic", ["python", "linear-algebra", "statistics"])
@pytest.mark.parametrize("minutes", [1, 45, MAX_MINUTES])
def test_every_topic_accepts_every_legal_duration(topic, minutes):
    session = Session("S-001", date(2026, 5, 4), topic, minutes)
    assert session.topic == topic
    assert 0 < session.minutes <= MAX_MINUTES


@pytest.mark.parametrize(
    "logged_on",
    [
        date(2026, 5, 4),
        date(1999, 1, 1),
        pytest.param(
            date(2026, 5, 4) + timedelta(days=3650),
            marks=pytest.mark.xfail(
                strict=True,
                reason="the stated rules never forbade a session dated in the future",
            ),
        ),
    ],
    ids=["today-ish", "long-ago", "far-future"],
)
def test_refuses_a_date_outside_the_practice_log(logged_on):
    """The third case is a known gap, recorded rather than quietly ignored.

    `strict=True` means the suite fails if this case ever starts passing —
    which is what you want, because that is the day somebody implemented the
    rule and forgot to delete the marker.
    """
    session = Session("S-001", logged_on, "python", 45)
    assert session.logged_on <= date(2026, 5, 4)
examples/test_store.py (1835 bytes)
"""The behaviour of the store, driven entirely through fixtures.

Compare this file with `starter/test_practice_store.py`. They test the same
things. This one has no copy-pasted setup, because every arrangement it needs
has a name and lives in `conftest.py`.
"""

from datetime import date

import pytest

from practice_store import DuplicateRef, Session, UnknownRef


def test_a_new_store_is_empty(empty_store):
    assert empty_store.all() == []
    assert empty_store.total_minutes() == 0


def test_add_then_find_round_trips(empty_store):
    added = empty_store.add(Session("S-100", date(2026, 6, 1), "pytest", 25))
    assert empty_store.find("S-100") == added


def test_refuses_a_duplicate_ref(loaded_store):
    with pytest.raises(DuplicateRef):
        loaded_store.add(Session("S-001", date(2026, 7, 1), "python", 15))


def test_refuses_an_unknown_ref(loaded_store):
    with pytest.raises(UnknownRef):
        loaded_store.find("S-999")


@pytest.mark.parametrize(
    ("topic", "expected"),
    [("python", 105), ("linear-algebra", 30), (None, 135)],
    ids=["python-only", "linear-algebra-only", "every-topic"],
)
def test_total_minutes_adds_the_right_sessions(loaded_store, topic, expected):
    assert loaded_store.total_minutes(topic) == expected


def test_topics_come_back_sorted(loaded_store):
    assert loaded_store.topics() == ["linear-algebra", "python"]


def test_teardown_reports_the_final_size(audited_store):
    audited_store.add(Session("S-004", date(2026, 5, 7), "pytest", 20))
    assert len(audited_store.all()) == 4


@pytest.mark.slow
def test_the_file_on_disk_is_plain_readable_csv(loaded_store):
    text = loaded_store.path.read_text(encoding="utf-8")
    header, first, *_ = text.splitlines()
    assert header == "ref,logged_on,topic,minutes"
    assert first == "S-001,2026-05-04,python,45"
metadata.yml (1099 bytes)
lesson_id: D072
day: 72
kind: python-program
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-072-fixtures-parametrization-and-test-design
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/pytest --version
run_commands:
  - cd starter && ../.venv/bin/pytest -q
  - cd starter && ../.venv/bin/pytest --collect-only -q
  - cd examples && ../.venv/bin/pytest -q
  - cd examples && ../.venv/bin/pytest --collect-only -q test_sessions.py
  - cd examples && ../.venv/bin/pytest --collect-only -q -k refuses
  - cd examples && ../.venv/bin/pytest --collect-only -q -m validation
  - cd examples && ../.venv/bin/pytest scopes -s -q
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 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 -> 38 checks, 0 failure(s), exit 0'
requirements/README.md (2950 bytes)
# Dependencies — Day 072 lab

**One third-party package. Free and open source. Installed once, then the
whole lab runs offline.**

| Dependency | Pinned version | Why this lab needs it | Licence and cost |
| --- | --- | --- | --- |
| `pytest` | `9.1.1` | Fixtures, parametrization, markers, `--collect-only`, and the built-in `tmp_path`, `capsys` and `monkeypatch` fixtures — the entire subject of the day | MIT licence, free and open source |
| `python3` | 3.8 or newer (tested on 3.14.0) | Runs everything; `dataclasses`, `csv`, `datetime` and `pathlib` are all standard library | Python Software Foundation Licence, free |
| `bash` | any (tested on 3.2.57) | The outer test harness | Free software, preinstalled on macOS and Linux |

Weeks 8 to 10 were standard-library-only on purpose. Week 11 is the first
week that genuinely needs a third-party tool, because the week is *about*
those tools — you cannot learn fixtures from a library that does not have
them. The version above was verified on 2026-07-19 by running
`pytest --version` on the authoring machine.

## The one-time install

You set up virtual environments on Day 43. The same three lines apply here,
run from the lab directory:

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
```

That last command should print `pytest 9.1.1`. The `.venv/` directory is
ignored by version control and is safe to delete and recreate at any time.

**The install is the only step that needs the network.** After it, every
command in this lab runs entirely offline: nothing here opens a socket,
reads the clock, or calls out to a service. If you are working somewhere
without a connection, install once while you have one.

## If you cannot install anything

The test harness will find pytest in three places, in this order: an explicit
`PYTEST=/path/to/pytest` on the command line, this lab's `.venv/bin/pytest`,
and finally whatever `pytest` is on your `PATH`. So if your system already
has a pytest — from a package manager, from a shared environment, from a
previous project — you can point the suite at it:

```bash
PYTEST=/usr/local/bin/pytest bash tests/run_tests.sh
```

A different pytest version will still run this lab. The exact collected
counts the harness asserts (36, 13, 11, 21, 35) depend on the test files, not
on the pytest version, and every flag used here — `--collect-only`, `-q`,
`-k`, `-m`, `-s`, `--strict-markers` — has been in pytest for many years.

## Why pin an exact version

`pytest==9.1.1` rather than `pytest` or `pytest>=9`. A pinned version is the
difference between "the suite passed" and "the suite passed, and I can tell
you exactly what ran". This lab asserts precise numbers of collected items
and precise summary strings; a floating dependency is a slow leak that turns
those assertions into a mystery six months from now. Pin in a lab, pin in a
project, and upgrade deliberately.
requirements/requirements.txt (14 bytes)
pytest==9.1.1
starter/practice_store.py (5816 bytes)
"""A tiny CSV-backed store of practice sessions — the code under test.

This module is deliberately small and deliberately ordinary. It is the kind of
thing you wrote on Days 64 and 65: a dataclass with a few rules, and a class
that reads and writes one CSV file. Nothing here knows that pytest exists.

The interesting part of this lab is not this file. It is the shape of the test
suite that surrounds it.
"""

from __future__ import annotations

import csv
from dataclasses import dataclass
from datetime import date
from pathlib import Path

FIELDNAMES = ["ref", "logged_on", "topic", "minutes"]

#: The longest single session the log will accept, in minutes.
MAX_MINUTES = 600


class StoreError(Exception):
    """Any rule of the practice-log domain that was refused."""


class InvalidSession(StoreError):
    """A session was built from values the rules forbid."""


class DuplicateRef(StoreError):
    """A reference already present in the store was added again."""


class UnknownRef(StoreError):
    """A reference the store has never seen was asked for."""


@dataclass(frozen=True)
class Session:
    """One practice session: a reference, a date, a topic, and a duration."""

    ref: str
    logged_on: date
    topic: str
    minutes: int

    def __post_init__(self) -> None:
        if not isinstance(self.ref, str) or not _looks_like_ref(self.ref):
            raise InvalidSession(f"reference must look like S-001, got {self.ref!r}")
        if not isinstance(self.logged_on, date):
            raise InvalidSession(f"logged_on must be a date, got {self.logged_on!r}")
        if not self.topic or self.topic != self.topic.strip().lower():
            raise InvalidSession(
                f"topic must be non-empty and lower case with no padding, got {self.topic!r}"
            )
        if isinstance(self.minutes, bool) or not isinstance(self.minutes, int):
            raise InvalidSession(f"minutes must be a whole number, got {self.minutes!r}")
        if self.minutes <= 0:
            raise InvalidSession(f"minutes must be positive, got {self.minutes}")
        if self.minutes > MAX_MINUTES:
            raise InvalidSession(
                f"minutes must be at most {MAX_MINUTES}, got {self.minutes}"
            )

    def as_row(self) -> dict[str, str]:
        """Render this session as the dictionary one CSV row holds."""
        return {
            "ref": self.ref,
            "logged_on": self.logged_on.isoformat(),
            "topic": self.topic,
            "minutes": str(self.minutes),
        }

    @classmethod
    def from_row(cls, row: dict[str, str]) -> "Session":
        """Rebuild a session from one CSV row, through the same rules."""
        try:
            minutes = int(row["minutes"])
        except (KeyError, TypeError, ValueError) as exc:
            raise InvalidSession(f"minutes column is not a whole number: {exc}") from exc
        try:
            logged_on = date.fromisoformat(row["logged_on"])
        except (KeyError, TypeError, ValueError) as exc:
            raise InvalidSession(f"logged_on column is not an ISO date: {exc}") from exc
        return cls(
            ref=row.get("ref", ""),
            logged_on=logged_on,
            topic=row.get("topic", ""),
            minutes=minutes,
        )


def _looks_like_ref(ref: str) -> bool:
    """True when ref is the letter S, a hyphen, and exactly three digits."""
    return (
        len(ref) == 5
        and ref[0] == "S"
        and ref[1] == "-"
        and ref[2:].isdigit()
    )


class PracticeStore:
    """A practice log kept in one CSV file.

    Every read goes to the file, so the store never holds a stale copy. That is
    a deliberate design choice for this lab: it makes the store slow enough
    that the cost of rebuilding it in every test is visible, which is exactly
    the pressure that makes fixtures and their scopes worth learning.
    """

    def __init__(self, path: Path | str) -> None:
        self.path = Path(path)

    def initialise(self) -> "PracticeStore":
        """Create the file with its header row, replacing anything there."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("w", newline="", encoding="utf-8") as handle:
            csv.DictWriter(handle, fieldnames=FIELDNAMES).writeheader()
        return self

    def add(self, session: Session) -> Session:
        """Append one session, refusing a reference the store already holds."""
        if any(existing.ref == session.ref for existing in self.all()):
            raise DuplicateRef(f"{session.ref} is already in the log")
        with self.path.open("a", newline="", encoding="utf-8") as handle:
            csv.DictWriter(handle, fieldnames=FIELDNAMES).writerow(session.as_row())
        return session

    def all(self) -> list[Session]:
        """Every session in the file, in the order it was written."""
        if not self.path.exists():
            return []
        with self.path.open(newline="", encoding="utf-8") as handle:
            return [Session.from_row(row) for row in csv.DictReader(handle)]

    def find(self, ref: str) -> Session:
        """The session with this reference, or UnknownRef."""
        for session in self.all():
            if session.ref == ref:
                return session
        raise UnknownRef(f"{ref} is not in the log")

    def total_minutes(self, topic: str | None = None) -> int:
        """Total minutes practised, optionally narrowed to one topic."""
        return sum(
            session.minutes
            for session in self.all()
            if topic is None or session.topic == topic
        )

    def topics(self) -> list[str]:
        """Every distinct topic in the log, in alphabetical order."""
        return sorted({session.topic for session in self.all()})
starter/pytest.ini (236 bytes)
[pytest]
# This file marks the root of the starter suite, so `pytest` run from here
# knows where it is. Exercise 6 asks you to add a `markers = ...` section and
# `addopts = --strict-markers`; until then there is nothing to configure.
starter/refactoring-worksheet.md (7255 bytes)
# Refactoring worksheet — a suite that scales

Work through these in order, from the `starter/` directory. Every command
below is copy-pasteable. Replace `pytest` with `.venv/bin/pytest` if you
installed the dependency into a lab-local virtual environment.

Fill in the measurement table at the bottom as you go. The point of this lab
is not that you end up with fewer lines — it is that you can say, with
numbers, what changed and why it is better.

---

## Exercise 0 — measure the starting point

```bash
pytest -q
pytest --collect-only -q | tail -2
grep -c 'store = PracticeStore(path)' test_practice_store.py
```

Record the three numbers in the table. On the authoring machine they were
`13 passed`, `13 tests collected`, and `8`.

Read the file once, top to bottom, and write one sentence here saying what is
wrong with it. "It repeats itself" is not enough — say what the repetition
*costs*.

> Your sentence:

---

## Exercise 1 — name the arrangement before you extract it

Six lines of setup appear eight times. Before writing any code, give the two
distinct arrangements a name. Good fixture names are nouns describing the
*state of the world*, not verbs describing the setup procedure.

- The store with a header row and no sessions is called: ______________
- The store holding the three sample sessions is called: ______________

If you find yourself writing `setup_store` or `make_store_and_add_things`,
try again. `empty_store` and `loaded_store` are the shape you want.

---

## Exercise 2 — extract the first fixture

Create a new file `conftest.py` **next to** `test_practice_store.py`. You do
not import it anywhere; pytest finds it by location.

```python
from datetime import date

import pytest

from practice_store import PracticeStore, Session


@pytest.fixture
def store_path(tmp_path):
    return tmp_path / "practice.csv"


@pytest.fixture
def empty_store(store_path):
    return PracticeStore(store_path).initialise()
```

Now rewrite `test_a_new_store_is_empty` and `test_add_then_find_round_trips`
so they take `empty_store` as a parameter instead of `tmp_path`, and delete
their setup lines. Run:

```bash
pytest -q
```

Still `13 passed`. You have changed the arrangement and not the assertions,
which is exactly what a refactor is.

---

## Exercise 3 — build a fixture on top of another fixture

Add a third fixture that requests the second one:

```python
@pytest.fixture
def loaded_store(empty_store):
    empty_store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    empty_store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    empty_store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    return empty_store
```

Rewrite the six remaining store tests to request `loaded_store`. Run the suite
and then count the duplication again:

```bash
pytest -q
grep -c 'store = PracticeStore(path)' test_practice_store.py
```

That count should now be `0`, and the test count unchanged at `13`.

---

## Exercise 4 — five tests become one, and stay five

The five rejection tests at the bottom of the file differ only in their
inputs. Replace all five with a single parametrized test:

```python
@pytest.mark.parametrize(
    ("ref", "topic", "minutes"),
    [
        ("001", "python", 45),
        ("S-1", "python", 45),
        ("S-001", "Python", 45),
        ("S-001", "python", 0),
        ("S-001", "python", MAX_MINUTES + 1),
    ],
    ids=[
        "ref-without-prefix",
        "ref-too-short",
        "topic-not-lower-case",
        "minutes-zero",
        "minutes-over-the-cap",
    ],
)
def test_refuses_a_bad_value(ref, topic, minutes):
    with pytest.raises(InvalidSession):
        Session(ref, date(2026, 5, 4), topic, minutes)
```

Now the check that matters. One function, five cases — how many tests does
pytest think it has?

```bash
pytest --collect-only -q | tail -2
```

The answer must still be `13 tests collected`. If it says `9`, the decorator
did not expand and you have five cases hiding inside one test that stops at
the first failure. Also confirm the ids are readable:

```bash
pytest --collect-only -q | grep refuses_a_bad_value
```

Every line should end in a square-bracketed name you chose, not `[ref0]`.

> How many test ids did you see, and what were they?

---

## Exercise 5 — the cross product

Add one more test with **two** stacked `parametrize` decorators — three topics
and three durations:

```python
@pytest.mark.parametrize("topic", ["python", "linear-algebra", "statistics"])
@pytest.mark.parametrize("minutes", [1, 45, MAX_MINUTES])
def test_every_topic_accepts_every_legal_duration(topic, minutes):
    session = Session("S-001", date(2026, 5, 4), topic, minutes)
    assert session.topic == topic
```

Predict the collected count before you run it, then check:

```bash
pytest --collect-only -q | tail -2
```

> Predicted: ______  Actual: ______

---

## Exercise 6 — markers and selection

Add this to `pytest.ini`:

```ini
addopts = --strict-markers
markers =
    validation: a test of the Session value rules — fast, no files touched
```

Put `pytestmark = pytest.mark.validation` at the top of the module, or mark
individual tests, and then select:

```bash
pytest --collect-only -q -m validation | tail -2
pytest --collect-only -q -k refuses | tail -2
```

`-m` selects by marker; `-k` matches a substring of the test id, parameter
names included. Try `-k "refuses and not minutes"` and explain the result.

> What `-k` expression would run only the two cases about references?

---

## Exercise 7 — teardown that runs anyway

Add a fixture that yields, with a `print` after the yield:

```python
@pytest.fixture
def audited_store(loaded_store):
    before = len(loaded_store.all())
    yield loaded_store
    after = len(loaded_store.all())
    print(f"[audit] sessions before: {before}, after: {after}")
```

Write one test that uses it and passes. Then write one that uses it and
fails on purpose, marked so the suite still goes green:

```python
@pytest.mark.xfail(strict=True, reason="fails on purpose, to show teardown still runs")
def test_teardown_runs_even_when_the_test_fails(audited_store):
    assert len(audited_store.all()) == 99
```

Run with `-s` so print output is not swallowed:

```bash
pytest -s -q
```

> Did the `[audit]` line appear for the failing test as well? Why does that
> guarantee matter more than a `try`/`finally` you write yourself?

---

## Measurement table

| Measurement | Before | After |
| --- | --- | --- |
| Lines in `test_practice_store.py` | | |
| Copies of the store setup (`grep -c`) | | |
| Test functions written by hand | | |
| Test items pytest collects | | |
| Assertions lost in the refactor | | `0` |

That last row is not optional. If any assertion disappeared, the refactor was
a rewrite, and a rewrite of a test suite is how coverage quietly evaporates.

---

## The design question

Answer this last, in two or three sentences, once everything passes.

You now have four fixtures. Pick the one you would delete first if a reviewer
said "this suite has too much indirection — I cannot tell what a test sets
up", and say what you would write in its place. A fixture used by one test is
usually a helper function wearing a costume.

> Your answer:
starter/test_practice_store.py (4697 bytes)
"""The suite you have inherited. It works. That is the problem.

Every test here passes. Nothing is broken, nothing is wrong, and no reviewer
would reject it on correctness. It is still a bad suite, and your job is to
say precisely why and then fix it without losing a single assertion.

Read it once before you touch it. Count how many times the same six lines of
setup appear. Then work through `refactoring-worksheet.md`, exercise by
exercise. The finished reference lives in `../examples/` — look at it after
you have tried, not before.
"""

from datetime import date

import pytest

from practice_store import (
    MAX_MINUTES,
    DuplicateRef,
    InvalidSession,
    PracticeStore,
    Session,
    UnknownRef,
)

# --- the store tests -------------------------------------------------------
# Exercise 2 and 3 are about everything below this line up to the next banner.


def test_a_new_store_is_empty(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    assert store.all() == []
    assert store.total_minutes() == 0


def test_add_then_find_round_trips(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    added = store.add(Session("S-100", date(2026, 6, 1), "pytest", 25))
    assert store.find("S-100") == added


def test_refuses_a_duplicate_ref(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    with pytest.raises(DuplicateRef):
        store.add(Session("S-001", date(2026, 7, 1), "python", 15))


def test_refuses_an_unknown_ref(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    with pytest.raises(UnknownRef):
        store.find("S-999")


def test_total_minutes_for_python(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    assert store.total_minutes("python") == 105


def test_total_minutes_for_linear_algebra(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    assert store.total_minutes("linear-algebra") == 30


def test_total_minutes_for_everything(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    assert store.total_minutes() == 135


def test_topics_come_back_sorted(tmp_path):
    path = tmp_path / "practice.csv"
    store = PracticeStore(path)
    store.initialise()
    store.add(Session("S-001", date(2026, 5, 4), "python", 45))
    store.add(Session("S-002", date(2026, 5, 5), "linear-algebra", 30))
    store.add(Session("S-003", date(2026, 5, 6), "python", 60))
    assert store.topics() == ["linear-algebra", "python"]


# --- the five near-identical rejection tests -------------------------------
# Exercise 4 is about everything below this line. Five tests, one assertion
# body, five different inputs. There is a decorator for exactly this.


def test_refuses_a_ref_without_the_prefix():
    with pytest.raises(InvalidSession):
        Session("001", date(2026, 5, 4), "python", 45)


def test_refuses_a_ref_that_is_too_short():
    with pytest.raises(InvalidSession):
        Session("S-1", date(2026, 5, 4), "python", 45)


def test_refuses_a_topic_that_is_not_lower_case():
    with pytest.raises(InvalidSession):
        Session("S-001", date(2026, 5, 4), "Python", 45)


def test_refuses_zero_minutes():
    with pytest.raises(InvalidSession):
        Session("S-001", date(2026, 5, 4), "python", 0)


def test_refuses_more_minutes_than_the_cap():
    with pytest.raises(InvalidSession):
        Session("S-001", date(2026, 5, 4), "python", MAX_MINUTES + 1)
tests/run_tests.sh (12992 bytes)
#!/usr/bin/env bash
# Tests for the Day 072 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# This is the outer harness. It does not test the practice store — the pytest
# suites inside examples/ and starter/ do that. This harness tests the SUITES:
#
#   * the refactored suite still passes;
#   * parametrization really expanded — the collected COUNT is exact, which is
#     the one check that distinguishes N independent tests from one loop;
#   * `-k` and `-m` select exactly the subsets the lesson claims;
#   * fixture scope is an observable number of executions, not a comment;
#   * `yield` teardown runs even when the test it served has failed;
#   * conftest discovery is directory-scoped — a fixture defined in a
#     subdirectory is invisible above it;
#   * --strict-markers turns a mistyped marker into an error;
#   * and, most important of all, breaking one line of the implementation
#     makes the suite FAIL. A suite that passes against broken code is not a
#     suite, it is decoration.
#
# 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)"
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
}

check_equals() {
  local label="$1" expected="$2" actual="$3"
  if [ "${expected}" = "${actual}" ]; then
    check "${label} (${actual})" "yes"
  else
    check "${label} — expected ${expected}, got ${actual}" "no"
  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
}

# collected <dir> [extra pytest args...] -> the number of items collected.
# Reads the summary line pytest prints in quiet collect mode, which is either
# "36 tests collected in 0.01s" or "11/36 tests collected (25 deselected)...".
collected() {
  local dir="$1"
  shift
  (cd "${dir}" && "${pytest_bin}" --collect-only -q "$@" 2>/dev/null) \
    | grep -E '[0-9]+ tests? collected' \
    | head -1 \
    | sed -E 's|^([0-9]+)(/[0-9]+)? tests? collected.*|\1|'
}

echo "Day 072 lab checks"
echo
echo "pytest: $("${pytest_bin}" --version 2>&1 | head -1)"
echo

# --------------------------------------------------------------------------
echo "1. The refactored suite"
# --------------------------------------------------------------------------

if (cd "${lab_dir}/examples" && "${pytest_bin}" -q >/dev/null 2>&1); then
  check "examples/ suite passes and exits 0" "yes"
else
  check "examples/ suite passes and exits 0" "no"
fi

summary="$(cd "${lab_dir}/examples" && "${pytest_bin}" -q 2>&1 | tail -1)"
case "${summary}" in
  *"39 passed, 2 xfailed"*) check "examples/ run summary is '39 passed, 2 xfailed'" "yes" ;;
  *) check "examples/ run summary is '39 passed, 2 xfailed' — got: ${summary}" "no" ;;
esac

if (cd "${lab_dir}/starter" && "${pytest_bin}" -q >/dev/null 2>&1); then
  check "starter/ suite passes as shipped (the refactor must not break it)" "yes"
else
  check "starter/ suite passes as shipped (the refactor must not break it)" "no"
fi

# --------------------------------------------------------------------------
echo
echo "2. Parametrization expanded — exact collected counts"
# --------------------------------------------------------------------------

check_equals "examples/ collects the full expanded suite" "41" "$(collected "${lab_dir}/examples")"
check_equals "starter/ collects its hand-written tests" "13" "$(collected "${lab_dir}/starter")"

# One function, six cases -> six independent items.
check_equals "test_refuses_a_bad_value expands to six items" "6" \
  "$(collected "${lab_dir}/examples" test_sessions.py -k test_refuses_a_bad_value)"

# Two stacked decorators, three values each -> the nine-item cross product.
check_equals "two stacked parametrize decorators give the 3x3 cross product" "9" \
  "$(collected "${lab_dir}/examples" test_sessions.py -k test_every_topic_accepts_every_legal_duration)"

ids="$(cd "${lab_dir}/examples" && "${pytest_bin}" --collect-only -q test_sessions.py 2>/dev/null \
  | grep 'test_refuses_a_bad_value' || true)"
case "${ids}" in
  *"[minutes-over-the-cap]"*) check "ids= produced readable test ids, not [ref0]" "yes" ;;
  *) check "ids= produced readable test ids, not [ref0]" "no" ;;
esac
case "${ids}" in
  *"[ref0]"*|*"[minutes0]"*) check "no generated placeholder ids remain" "no" ;;
  *) check "no generated placeholder ids remain" "yes" ;;
esac

# --------------------------------------------------------------------------
echo
echo "3. Selection: -k expressions and registered markers"
# --------------------------------------------------------------------------

check_equals "-k refuses selects exactly the eleven refusal items" "11" \
  "$(collected "${lab_dir}/examples" -k refuses)"
check_equals "-m validation selects exactly the value-rule module" "21" \
  "$(collected "${lab_dir}/examples" -m validation)"
check_equals "-m 'not slow' deselects exactly the one file-reading test" "40" \
  "$(collected "${lab_dir}/examples" -m "not slow")"

# --------------------------------------------------------------------------
echo
echo "4. Fixture scope is an observable number of executions"
# --------------------------------------------------------------------------

scope_log="$(cd "${lab_dir}/examples" && "${pytest_bin}" scopes -s -q 2>&1)"
check_equals "session-scoped fixture body ran once for the whole run" "1" \
  "$(printf '%s\n' "${scope_log}" | grep -c '\[session\] body ran')"
check_equals "module-scoped fixture body ran once per module (two modules)" "2" \
  "$(printf '%s\n' "${scope_log}" | grep -c '\[module \] body ran')"
check_equals "function-scoped fixture body ran once per test (four tests)" "4" \
  "$(printf '%s\n' "${scope_log}" | grep -c '\[funct  \] body ran')"
check_equals "every function-scoped setup was matched by a teardown" "4" \
  "$(printf '%s\n' "${scope_log}" | grep -c '\[funct  \] teardown ran')"

# The fourth function-scoped test is the one that fails on purpose. Four
# setups and four teardowns therefore proves teardown ran after a failure.
case "${scope_log}" in
  *"4 passed, 1 xfailed"*) check "the deliberately failing test is reported as xfailed" "yes" ;;
  *) check "the deliberately failing test is reported as xfailed" "no" ;;
esac

audit_log="$(cd "${lab_dir}/examples" && "${pytest_bin}" -s -q test_store.py 2>&1)"
case "${audit_log}" in
  *"[audit] sessions before: 3, after: 4"*) check "yield-fixture teardown observed the test's changes" "yes" ;;
  *) check "yield-fixture teardown observed the test's changes" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "5. The built-in fixtures behave as documented"
# --------------------------------------------------------------------------

check_equals "the built-in fixture demonstrations all pass" "5" \
  "$(collected "${lab_dir}/examples" test_builtin_fixtures.py)"

# monkeypatch must undo itself: the test that asserts the environment is back
# to its original value runs AFTER the one that changed it, and passes.
if (cd "${lab_dir}/examples" && "${pytest_bin}" -q test_builtin_fixtures.py >/dev/null 2>&1); then
  check "monkeypatch's change did not survive the test that made it" "yes"
else
  check "monkeypatch's change did not survive the test that made it" "no"
fi

for builtin in tmp_path capsys monkeypatch; do
  if grep -q "def test_[a-z_]*(.*${builtin}" "${lab_dir}/examples/test_builtin_fixtures.py"; then
    check "a test demonstrates the built-in ${builtin} fixture" "yes"
  else
    check "a test demonstrates the built-in ${builtin} fixture" "no"
  fi
done

# --------------------------------------------------------------------------
echo
echo "6. conftest.py discovery is directory-scoped"
# --------------------------------------------------------------------------

leak_dir="$(mktemp -d "${TMPDIR:-/tmp}/day072-leak.XXXXXX")"
cp -R "${lab_dir}/examples/." "${leak_dir}/"
cat > "${leak_dir}/test_scope_leak.py" <<'PY'
def test_a_subdirectory_fixture_is_not_visible_here(session_scoped):
    assert session_scoped == "session-value"
PY
leak_out="$(cd "${leak_dir}" && "${pytest_bin}" -q test_scope_leak.py 2>&1)"
leak_rc=$?
if [ "${leak_rc}" -ne 0 ]; then
  check "a fixture from scopes/conftest.py is unusable outside scopes/" "yes"
else
  check "a fixture from scopes/conftest.py is unusable outside scopes/" "no"
fi
case "${leak_out}" in
  *"fixture 'session_scoped' not found"*) check "pytest names the missing fixture in its error" "yes" ;;
  *) check "pytest names the missing fixture in its error" "no" ;;
esac
rm -rf "${leak_dir}"

# --------------------------------------------------------------------------
echo
echo "7. --strict-markers turns a typo into an error"
# --------------------------------------------------------------------------

marker_dir="$(mktemp -d "${TMPDIR:-/tmp}/day072-marker.XXXXXX")"
cp -R "${lab_dir}/examples/." "${marker_dir}/"
cat > "${marker_dir}/test_typo_marker.py" <<'PY'
import pytest


@pytest.mark.slwo
def test_with_a_mistyped_marker():
    assert True
PY
if (cd "${marker_dir}" && "${pytest_bin}" -q test_typo_marker.py >/dev/null 2>&1); then
  check "an unregistered marker is rejected under --strict-markers" "no"
else
  check "an unregistered marker is rejected under --strict-markers" "yes"
fi
rm -rf "${marker_dir}"

# --------------------------------------------------------------------------
echo
echo "8. The suite fails when the implementation is broken"
# --------------------------------------------------------------------------

break_dir="$(mktemp -d "${TMPDIR:-/tmp}/day072-break.XXXXXX")"
cp -R "${lab_dir}/examples/." "${break_dir}/"
# One character of damage: a session of zero minutes is now accepted.
sed -e 's|if self.minutes <= 0:|if self.minutes < 0:|' \
  "${lab_dir}/examples/practice_store.py" > "${break_dir}/practice_store.py"

if grep -q 'if self.minutes < 0:' "${break_dir}/practice_store.py"; then
  check "the deliberate break was applied to the copy" "yes"
else
  check "the deliberate break was applied to the copy" "no"
fi

break_out="$(cd "${break_dir}" && "${pytest_bin}" -q 2>&1)"
if (cd "${break_dir}" && "${pytest_bin}" -q >/dev/null 2>&1); then
  check "broken implementation makes the suite fail" "no"
else
  check "broken implementation makes the suite fail" "yes"
fi
case "${break_out}" in
  *"minutes-zero"*) check "the failure names the exact parametrized case: [minutes-zero]" "yes" ;;
  *) check "the failure names the exact parametrized case: [minutes-zero]" "no" ;;
esac
# The other five cases of the same function must still pass — that is what
# independent test items buy you over a loop inside one test.
case "${break_out}" in
  *"1 failed"*) check "exactly one of the six parametrized cases failed" "yes" ;;
  *) check "exactly one of the six parametrized cases failed" "no" ;;
esac
rm -rf "${break_dir}"

# --------------------------------------------------------------------------
echo
echo "9. The lab's own premise holds"
# --------------------------------------------------------------------------

starter_dupes="$(grep -c 'store = PracticeStore(path)' "${lab_dir}/starter/test_practice_store.py")"
check_equals "starter/ really does copy-paste its setup" "8" "${starter_dupes}"

if grep -q 'PracticeStore(' "${lab_dir}/examples/test_store.py"; then
  check "examples/test_store.py constructs no store of its own" "no"
else
  check "examples/test_store.py constructs no store of its own" "yes"
fi

for name in store_path empty_store loaded_store audited_store sample_sessions; do
  if grep -q "^def ${name}(" "${lab_dir}/examples/conftest.py"; then
    check "conftest.py defines the ${name} fixture" "yes"
  else
    check "conftest.py defines the ${name} fixture" "no"
  fi
done

if grep -q 'tmp_path' "${lab_dir}/examples/conftest.py"; then
  check "the store fixture is built on the built-in tmp_path fixture" "yes"
else
  check "the store fixture is built on the built-in tmp_path fixture" "no"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
if [ "${failures}" -ne 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting — Day 072 lab

Everything below has been reproduced on the authoring machine. Each entry gives the message you will actually see, the cause, and the fix.


FAIL: pytest not found.

The harness looked in three places and found nothing: the PYTEST environment variable, .venv/bin/pytest inside this lab, and your PATH.

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
bash tests/run_tests.sh

If you already have a pytest somewhere else, point the harness at it instead of installing a second copy:

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

fixture 'empty_store' not found

pytest looked for a fixture with that name and did not find one in scope. The message lists every fixture it did find, which is the fastest way to see what went wrong. Three usual causes:

  1. The conftest.py is in the wrong directory. It must sit beside the test file, or in a parent directory of it. conftest.py is never imported by name — pytest finds it by walking up from the test file.
  2. The name is misspelled. The parameter name is the lookup key. There is no other link between the test and the fixture.
  3. The fixture is defined in a subdirectory. A conftest.py inside scopes/ is invisible to files above it. That is deliberate, and the test harness checks it: a fixture defined in examples/scopes/conftest.py cannot be requested from examples/test_store.py.

To see everything available to a given file:

pytest --fixtures test_store.py

'slwo' not found in 'markers' configuration option

You used a marker that is not registered, and addopts = --strict-markers in pytest.ini turned that from a warning into an error. This is a feature: an unregistered marker is almost always a typo, and without strict mode @pytest.mark.slwo silently marks nothing, so -m slow quietly skips a test you thought you were running.

Fix the spelling, or register the marker:

[pytest]
markers =
    slow: a test that reads the CSV file back off disk

pytest --collect-only -q says 9 when you expected 13

Your @pytest.mark.parametrize did not expand. Check, in order:

  • Is the decorator spelled parametrize? Not parameterize. The British and American spellings differ and pytest uses the shorter one.
  • Do the names in the first argument match the function's parameters exactly? ("ref", "topic", "minutes") needs a function taking ref, topic, minutes.
  • Is the second argument a list of tuples when there is more than one parameter? A flat list of values with a three-name first argument raises a collection error rather than expanding.

In test_x: function uses no argument 'topic'

You listed a parameter name in parametrize that the test function does not accept. pytest refuses to run rather than silently ignoring it. Add the parameter to the function signature, or remove it from the decorator.


The [audit] print never appears

pytest captures standard output and only shows it for failing tests. That is usually what you want. To see it for passing tests too:

pytest -s -q

-s is shorthand for --capture=no. Use it while you are exploring, not in a committed configuration — captured output is what keeps a passing run quiet.


Test ids look like [ref0], [ref1], [ref2]

You did not pass ids=. pytest generates ids from the values when it can and falls back to <name><index> when it cannot — which happens for tuples, objects, and anything without a short readable representation. The suite still runs; the failure report just stops being useful. Add an ids= list with one readable string per case, in the same order.


PytestUnknownMarkWarning but the run still passes

Same cause as the strict-markers error above, without --strict-markers switched on. Add addopts = --strict-markers to pytest.ini so the warning becomes a failure. A warning in a hundred-line output is a warning nobody reads.


ModuleNotFoundError: No module named 'practice_store'

You ran pytest from the wrong directory. Both suites keep the module under test beside the tests, and pytest puts a test file's own directory on the import path. Run from examples/ or from starter/, not from the lab root:

cd examples && pytest -q

A test passes alone and fails as part of the suite

This is the classic symptom of shared mutable state, and it is worth stopping to understand rather than working around. Some fixture with a scope wider than function is being mutated by one test and observed by another, so the result depends on the order tests happen to run in.

Find it by narrowing:

pytest -q path/to/test_file.py::test_that_fails      # passes alone?
pytest -q -k "test_suspect or test_that_fails"       # fails together?

The fix is almost never "make the tests run in a fixed order". It is to move the mutable thing back to function scope, or to make it immutable. In this lab, sample_sessions is session-scoped only because frozen dataclasses cannot be mutated; every store fixture is function-scoped for exactly this reason.


E fixture 'tmp_path' not found after you renamed something

tmp_path is a built-in, so this message means pytest itself is not the one running your test — usually because you executed the file directly with python3 test_store.py instead of through pytest. Fixtures only exist inside a pytest run.


The suite passes but you do not believe it

Good instinct. Prove it can fail, the way Day 71 taught:

cp -R examples /tmp/day072-check
sed -i.bak 's|if self.minutes <= 0:|if self.minutes < 0:|' /tmp/day072-check/practice_store.py
cd /tmp/day072-check && pytest -q | tail -3

You should see 1 failed, 33 passed, 2 xfailed and the case named as test_refuses_a_bad_value[minutes-zero]. If the suite still passes, the tests are not testing what you think they are. (On macOS, sed -i.bak leaves a .bak file behind; delete the directory afterwards with rm -rf /tmp/day072-check.)


Anything else

Run the harness and read the labelled checks — each one names the property it was testing, so a FAIL: line tells you which of the day's ideas has come loose:

bash tests/run_tests.sh

Security notes

Security notes — Day 072 lab

  • What the lab does. It runs two small pytest suites over a CSV-backed practice log. Every file it writes goes into a temporary directory that pytest creates for the test that asked for it, and the bash harness makes its own scratch directories with mktemp -d and removes each one as that check finishes. Nothing here opens a network socket, reads the clock, needs a privilege, or touches a path you did not point it at.

  • The one networked moment is the install. pip install -r requirements/requirements.txt downloads pytest from the Python Package Index. That is the only step in this lab that leaves your machine, and it is worth treating as a real trust decision rather than a formality:

    • Install into a virtual environment (python3 -m venv .venv), never into the system Python. A lab-local .venv can be deleted; a damaged system Python cannot be deleted so casually.
    • Pin the version. pytest==9.1.1, not pytest. An unpinned requirement means the bytes you install today and the bytes a colleague installs next month are not the same bytes, and neither of you can say what changed.
    • Check the name. Typo-squatting on package indexes is a real and recurring attack: a package one character away from a popular name, published to catch mistyped installs. Read the requirement line before you run the command.
    • Everything installed here is free and open source under the MIT licence, and pytest's own documentation is the place to confirm that rather than taking this file's word for it.
  • tmp_path is a security feature, not just a convenience. A test that writes to a hard-coded path — /tmp/test.csv, or worse, a file beside the source — can clobber real data, can collide with another test, and can leave residue that makes the next run pass for the wrong reason. tmp_path gives every test a fresh directory nobody else is using, and pytest keeps only the last few runs so the disk does not fill. Every fixture in this lab that needs a file builds it from tmp_path.

  • monkeypatch undoes itself, and that matters. The lab's built-in fixture demonstration sets an environment variable with monkeypatch.setenv and a later test asserts the variable is gone. Hand-rolled patching — assigning to os.environ directly, or replacing a module attribute — survives the test that did it, so a test that changed a credential path or an endpoint can silently change what a later test talks to. Undo-on-teardown is not tidiness; it is containment.

  • Test data is data. The practice log in this lab is invented and harmless. Real suites are where production exports quietly end up as "fixtures", and a fixture file is a file in version control forever, visible to everyone with repository access and to every future clone. Generate test data, or anonymise it deliberately; never paste a production extract into a test directory.

  • Never put a credential in a test, a fixture, or a conftest.py. conftest.py looks like configuration and gets read less carefully than application code, which makes it a favourite hiding place for an API key someone added "just to get the suite running". Read secrets from the environment, and let monkeypatch.setenv supply a fake one inside the test. Day 74 goes further into testing at boundaries, which is where this problem properly belongs.

  • A test suite is executable code with your permissions. conftest.py files are imported and run automatically, from every directory pytest walks through, before any test does. That is exactly why you should read the test directory of an unfamiliar repository before running its suite — the same care you would give any script you downloaded. The suites in this lab do nothing but define fixtures and assertions; you can confirm that by reading them, which takes about two minutes.

  • What the harness deliberately breaks, and where. One check copies examples/ into a mktemp -d directory, damages a single comparison with sed, and confirms the suite fails. The damage happens only to the copy, the copy is removed immediately afterwards, and the lab's own files are never modified. If you reproduce that check by hand, do the same — copy first, and delete the copy when you are done.