Programming with PythonTesting and Code Quality › Day 74

Hands-on lab — Day 74: Mocking and Testing Boundaries

Commands

Setup

cd labs/sections/programming-with-python/day-074-mocking-and-testing-boundaries
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version

Run

python3 examples/demo.py
python3 examples/doubles_demo.py
python3 examples/autospec_demo.py
.venv/bin/pytest examples/test_patch_right_target.py examples/test_report_v2_fakes.py examples/test_model_boundary.py -q
.venv/bin/pytest examples/test_autospec_naive.py -q   # passes, and should not
.venv/bin/pytest examples/test_autospec_specced.py -q   # fails on purpose
.venv/bin/pytest examples/test_patch_wrong_target.py -q   # fails on purpose
.venv/bin/pytest starter -q

Test

bash tests/run_tests.sh

File tree

examples/adapters.py
examples/autospec_demo.py
examples/demo.py
examples/doubles_demo.py
examples/fakes.py
examples/model_boundary.py
examples/report_v1.py
examples/report_v2.py
examples/sensor_client.py
examples/sensor_service.py
examples/test_autospec_naive.py
examples/test_autospec_specced.py
examples/test_model_boundary.py
examples/test_patch_right_target.py
examples/test_patch_wrong_target.py
examples/test_report_v2_fakes.py
examples/typo_under_test.py
expected-output/FIELDS.md
expected-output/pytest-runs.txt
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/fakes.py
starter/NOTES.md
starter/report_v1.py
starter/report_v2.py
starter/sensor_service.py
starter/test_report_v1.py
starter/test_report_v2.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 074 lab — Test the Logic, Stub the World

Lesson

Purpose

Day 74 is the day the week's testing techniques meet code that is not a pure function. You are given a function that cannot be tested. write_daily_report reads the clock, calls a remote service, computes a summary, and writes a file — four responsibilities inside one body, three of them boundaries. There is no way to check the summary without also paying for the other three.

You will fix it twice, and compare.

First the painful way. Leave the function exactly as it is and test it with unittest.mock.patch: two patched names, a stubbed module object, a temporary directory, and one string target that must be exactly right or the test silently tests nothing. Along the way you will meet the rule that trips everybody — patch where the name is looked up, not where it was defined — and you will watch the wrong target fail in front of you rather than being told about it.

Then the real way. Refactor until the clock and the client are arguments. Write six test doubles by hand — a dummy, two stubs, two spies and a fake, none longer than fifteen lines — and test the same behaviour with no patching at all, no temporary directory, and nothing to undo. The suite proves the result by running your core from a directory containing no files, exactly as Day 70 proved its domain core did no input or output.

Along the way the lab demonstrates, with real captured failures, the single most expensive mistake in this whole subject: an un-specced Mock() accepts a misspelled method name, so a test can be green while production raises AttributeError on its first request. You will see the naive test pass, the autospec'd test fail, and the real object break — three runs, same bug.

The last piece is the one that pays off later. A language model call is a boundary with all three bad properties at once: slow, metered, and different every time. examples/model_boundary.py puts one behind an injected interface and tests the prompt building, the parsing, the retries and the failure path deterministically, in milliseconds, for nothing.

Learning objectives

  • Name the six boundaries a unit test must not cross — the network, the clock, the filesystem, randomness, the environment, and other processes — and say which of slow, flaky or non-deterministic each one makes a test.
  • Distinguish the five kinds of test double (dummy, stub, spy, mock, fake) and write one of each by hand in under fifteen lines.
  • Use unittest.mock properly: Mock, MagicMock, return_value, side_effect for both sequences and exceptions, assert_called_once_with, and call_args.
  • Demonstrate why an un-specced Mock() is dangerous, and fix it with spec= and create_autospec — including the case only autospec catches, a call with arguments the real method does not accept.
  • Aim patch at the name a module looks up, explain why from x import y changes the target, and prove that a patch is undone on exit from its block.
  • Refactor a function so its boundaries arrive as parameters, and test the result with hand-written fakes instead of patches.
  • Assert on a retry schedule and a backoff without any test ever waiting.
  • Test code that calls a language model without calling one, and explain what belongs in an evaluation suite instead.
  • Judge, in writing, which of the two approaches each of your own tests should use.

Prerequisites

  • Day 71: what a test is, assert, running pytest, reading its output.
  • Day 72: fixtures and @pytest.mark.parametrize; tmp_path in particular.
  • Day 73: writing the failing test first — the discipline this lab assumes.
  • Day 70: the pure domain core, the adapter ring, and the proof by running the core from an empty directory. Today is that idea applied to testing.
  • Day 66: raising exceptions on purpose and designing a small exception family.
  • Day 69: dataclasses, frozen=True, and type hints.
  • Day 43: creating a virtual environment with python3 -m venv.

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.10+ and bash).
  • Windows — use WSL and follow the Linux path. Several headings and the report's first line contain an em dash, so a UTF-8 terminal is needed for them to render; the numbers, the exit codes and the assertions are unaffected.

Hardware requirements

Any computer that runs Python 3. The whole lab is a few hundred lines of code, the test suite finishes in a couple of seconds, and the only files written are inside temporary directories. No special memory, disk, GPU, or network at test time.

Required software

  • python3 (3.10 or newer; tested on 3.14.0).
  • pytest 9.1.1 — the one dependency, installed below.
  • bash for the test runner (preinstalled on macOS and Linux).
  • unittest.mock — already present. It is part of the standard library and needs no installation.

Free and open-source options

Everything here is free and open source: Python, bash, the standard library, and pytest (MIT — see requirements/README.md). No account, no API key, no purchase, and no network access at any point after the one-time install.

The lesson's Alternatives section discusses pytest-mock, responses, requests-mock and freezegun. All four are free and open source, and none of them is used here — the lab's argument is that hand-written doubles beat all of them for a program this size, and it would be strange to make that argument while depending on one.

Installation

cd labs/sections/programming-with-python/day-074-mocking-and-testing-boundaries
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. .venv/ is ignored by version control — never commit it. If you already have pytest elsewhere, skip the virtual environment and run the suite as PYTEST=/path/to/pytest bash tests/run_tests.sh.

File structure

day-074-mocking-and-testing-boundaries/
├── README.md                        ← you are here
├── metadata.yml                     ← machine-readable lab metadata
├── examples/
│   ├── sensor_service.py            ← the "world": slow and flaky on purpose, no sockets
│   ├── sensor_client.py             ← the same boundary as an object (used for spec=/autospec)
│   ├── report_v1.py                 ← BEFORE: clock, network and filesystem hard-coded
│   ├── report_v2.py                 ← AFTER: a pure core, boundaries as parameters
│   ├── adapters.py                  ← the adapter ring: system clock, live client, file writer
│   ├── fakes.py                     ← the six hand-written doubles, plus a scripted model
│   ├── model_boundary.py            ← a language-model call behind an injected interface
│   ├── typo_under_test.py           ← production code with a real typo in it
│   ├── demo.py                      ← the whole story in one run
│   ├── doubles_demo.py              ← the five kinds of double, demonstrated
│   ├── autospec_demo.py             ← why an un-specced Mock is dangerous
│   ├── test_patch_right_target.py   ← passes
│   ├── test_patch_wrong_target.py   ← FAILS on purpose
│   ├── test_autospec_naive.py       ← passes on purpose, and is worthless
│   ├── test_autospec_specced.py     ← FAILS on purpose, and is right
│   ├── test_report_v2_fakes.py      ← passes, with no patching anywhere
│   └── test_model_boundary.py       ← passes, without calling a model
├── starter/
│   ├── sensor_service.py            ← provided, identical to the example
│   ├── report_v1.py                 ← provided complete; this is what you test
│   ├── test_report_v1.py            ← YOUR working file (exercises 1-2)
│   ├── report_v2.py                 ← YOUR working file (exercise 3)
│   ├── fakes.py                     ← YOUR working file (exercise 4)
│   ├── test_report_v2.py            ← YOUR working file (exercise 5)
│   └── NOTES.md                     ← YOUR written comparison (exercise 6)
├── tests/
│   └── run_tests.sh                 ← 49 checks; exits 0 only if all pass
├── expected-output/
│   ├── sample-run.txt               ← real captured runs of the three demos
│   ├── pytest-runs.txt              ← real captured runs of all six example suites
│   ├── test-run.txt                 ← real captured run of the test suite
│   └── FIELDS.md                    ← required behaviour, and what varies between runs
├── requirements/
│   ├── requirements.txt             ← pytest==9.1.1
│   └── README.md                    ← what each dependency is for, and what is stdlib
├── troubleshooting.md
└── security.md

How to run

From this directory. pt below is your pytest: .venv/bin/pytest after the install above.

## 1. See what a boundary costs, and what removing it buys. One run, six parts.
python3 examples/demo.py

## 2. Meet the five kinds of test double, each in a few real lines.
python3 examples/doubles_demo.py

## 3. Watch an un-specced Mock accept everything, and autospec refuse.
python3 examples/autospec_demo.py

## 4. The suites that pass.
.venv/bin/pytest examples/test_patch_right_target.py examples/test_report_v2_fakes.py examples/test_model_boundary.py -q

## 5. The suite that passes and should not. Read it — it is four lines.
.venv/bin/pytest examples/test_autospec_naive.py -q

## 6. The two suites that FAIL on purpose. Read the failures; that is the point.
.venv/bin/pytest examples/test_autospec_specced.py -q
.venv/bin/pytest examples/test_patch_wrong_target.py -q

## 7. Prove the refactored core needs no patching, from a directory with no files.
cd "$(mktemp -d)" && PYTHONPATH=$OLDPWD/examples python3 -c "
import datetime
from report_v2 import build_report
from fakes import StubSensorClient, frozen_clock
day = datetime.date(2026, 4, 12)
print(build_report('ALPHA', clock=frozen_clock(day),
                   client=StubSensorClient([12.0, 14.0, 20.0, 22.0] * 6)).render())
" && cd -

## 8. Your task: exercises 1-2 in starter/test_report_v1.py, 3 in
##    starter/report_v2.py, 4 in starter/fakes.py, 5 in
##    starter/test_report_v2.py, 6 in starter/NOTES.md.
.venv/bin/pytest starter -q

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

What the commands do

  • python3 examples/demo.py — the whole lab in one run. Section 1 calls the stand-in service twice and times it, so you can see latency and randomness rather than read about them. Section 2 runs the same patch test against the right target and the wrong one, printing the mean each produced and how long it took — the wrong target takes 0.4 s because the real function ran. Section 3 builds a report with fakes in a fraction of a millisecond. Section 4 proves a retry schedule without anything waiting. Section 5 shows the model boundary. Section 6 prints the imports of both versions side by side.
  • python3 examples/doubles_demo.py — dummy, stub, spy, mock and fake, each exercised against the same build_report, then a short demonstration that a single Mock() can play all five roles depending on which of its features you use. That ambiguity is why the five names are worth keeping.
  • python3 examples/autospec_demo.py — the most important twenty lines in the lab. A bare Mock() invents fetch_radings and accepts a call with a keyword the real method has never heard of. Mock(spec=SensorClient) refuses the invented attribute but still accepts the bad signature. create_autospec(SensorClient, instance=True) refuses both. Then the same buggy function is called three ways: with a bare Mock it returns 20.0, with autospec it raises AttributeError, and with the real SensorClient it raises the same AttributeError — which is what production would have done.
  • The pytest commands — six suites. Four pass; two fail on purpose. The two failures are not defects in the lab, and the test suite asserts that they happen. A suite that could not tell test_autospec_naive.py from test_autospec_specced.py would be proving nothing.
  • The mktemp -d one-liner — imports the refactored core from a directory containing no files at all and prints a complete report. Nothing is patched, nothing is stubbed by a library, and no fixture exists. This is Day 70's proof, applied to testing.
  • bash tests/run_tests.sh — 49 checks while the starter is unfinished, 39 once you complete every exercise. Exits 0 only if all of them pass.

Expected output

See expected-output/sample-run.txt and expected-output/pytest-runs.txt — real captured sessions. The heart of it:

$ python3 examples/demo.py

2. report_v1 under patch — target matters
=========================================
  report_v1.py says: from sensor_service import fetch_readings
  so the name lives in report_v1's namespace, not sensor_service's.
  patch('report_v1.fetch_readings')
      -> mean     17.0   [0.00s — the stub was used]
  patch('sensor_service.fetch_readings')
      -> mean     8.6   [0.40s — the REAL service was used]
  the second target is not an error. It patches something nobody looks at.
$ python3 examples/autospec_demo.py

The bug this hides, in three lines
==================================
  typo_under_test.latest_average calls client.fetch_radings(...)
  with a bare Mock (this is what the green test does)
      -> 20.0
  with create_autospec (this is what a good test does)
      -> AttributeError: Mock object has no attribute 'fetch_radings'
  with the REAL SensorClient (this is production)
      -> AttributeError: 'SensorClient' object has no attribute 'fetch_radings'

Only two things vary between runs, and both are the lesson rather than a defect: the readings and timings in section 1 of demo.py (that section calls the real stand-in service on purpose) and the object ids inside Mock reprs. expected-output/FIELDS.md lists the exact required behaviour of every double, the core, and the model boundary.

Validation steps

  1. python3 examples/demo.py exits 0, and section 2 shows the right target at about 0.00s and the wrong target at about 0.40s.
  2. In examples/autospec_demo.py, the bare Mock returns 20.0 for a function whose call is misspelled, while the real SensorClient raises AttributeError. Those two lines are the whole argument for autospec.
  3. pytest examples/test_autospec_naive.py -q reports 1 passed, and pytest examples/test_autospec_specced.py -q reports 2 failed. Both are correct.
  4. pytest examples/test_patch_wrong_target.py -q reports 1 failed in about 0.4 s. Change its one patch target to report_v1.fetch_readings, rerun, and watch it pass in about 0.01 s. Change it back.
  5. pytest examples/test_report_v2_fakes.py -q reports 10 passed in under 0.05 s and contains no patch call at all — check with grep -cE 'patch\(' examples/test_report_v2_fakes.py, which prints 0. (Plain grep -c patch prints 2: the word appears twice in the file's opening comment, which is a small lesson in why the test suite parses these files instead of searching their text.)
  6. The mktemp -d one-liner in step 7 of "How to run" prints a five-line report from a directory containing no files.
  7. grep -nE '^\s*(import|from) (pathlib|time|random|os|json)' examples/report_v2.py finds nothing. The suite checks this too, by parsing the file rather than grepping it.
  8. In examples/test_report_v2_fakes.py, the backoff test asserts waits.waits == [0.5, 1.0] and the whole file still runs in hundredths of a second — nothing waited.
  9. Every exercise in starter/ is complete, starter/NOTES.md is filled in with sentences rather than single words, and .venv/bin/pytest starter -q passes.
  10. bash tests/run_tests.sh reports 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 49 checks, 0 failure(s). Once you complete every exercise, ten structural checks are replaced by four behavioural ones and the line becomes 39 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in continuous integration. A full captured run is in expected-output/test-run.txt.

Four of the checks assert that a test suite fails, which is unusual enough to be worth reading the runner for. They are the autospec pair, the patch-target pair, and one more that matters just as much: the suite copies the examples to a temporary directory, breaks exactly one line of report_v2.py with sed so the mean is always zero, and asserts that test_report_v2_fakes.py then fails. A test suite that stays green against a broken implementation is not a test suite.

Cleanup

The lab writes nothing into your working directory. Every file it creates goes into a directory made with mktemp -d or pytest's tmp_path, and is removed when that check or test finishes. The runner passes -p no:cacheprovider, so pytest leaves no cache directory behind either.

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

Troubleshooting

See troubleshooting.md for the full list. The five you are most likely to meet: a patch that appears to do nothing (you aimed it at where the function was defined); AttributeError: <module> does not have the attribute from patch (a typo in the target string, which is the good case — patch checks the attribute exists); a test that passes for a minute and then fails (something real is still being called); ModuleNotFoundError: No module named 'report_v2' (pytest was pointed at a file outside the directory holding the modules); and NotImplementedError from the starter, which is expected until you finish that exercise.

Security notes

See security.md. Short version: nothing here opens a socket — sensor_service.py simulates latency and failure with time.sleep and random, and contacts nothing. Patching is a run-time modification of another module's namespace and is undone on exit; a patch that escapes its block corrupts every test after it, which is why with and the decorator form are safer than manual start()/stop(). And the day's real security point: a test suite full of mocks can be green while the system is broken, so mock-heavy suites are a poor place to put your confidence about anything that matters.

Extension exercises

  1. Make the naive test fail honestly. Add spec=SensorClient to the bare Mock() in examples/test_autospec_naive.py and rerun. Then change it to create_autospec and add a call with a keyword argument the real method does not take. Note which of the two levels caught which mistake.
  2. Change the import style and watch the target move. In examples/report_v1.py, replace from sensor_service import fetch_readings with import sensor_service, and change the call to sensor_service.fetch_readings(...). Now run both patch tests again. They swap places: the "wrong" target becomes the right one. Write down, in one sentence, the rule that explains both results. Then put the file back.
  3. Add a boundary and remove it again. Give the report a generated_by field filled from an environment variable. First test it with monkeypatch.setenv, then refactor so the value is a parameter and test it with neither. Compare the two tests line for line.
  4. Write a contract test. LiveSensorClient in adapters.py and FakeSensorClient in fakes.py both claim to implement the same interface. Write one parametrized test that runs the same assertions against both, so the fake cannot drift away from the real thing unnoticed. Mark the live case so it is skipped by default — the point is that it can be run, not that it runs on every commit.
  5. Break the boundary on purpose. Add import time and time.sleep(0.1) to examples/report_v2.py, then run bash tests/run_tests.sh. Watch the purity check fail, and note that it fails by parsing the file rather than by searching its text. Remove it again.
  6. Replace a mock with a fake. Rewrite one test in examples/test_patch_right_target.py so it uses create_autospec and assert_called_once_with instead of a plain patch, then rewrite it a third time against report_v2 with FakeSensorClient. Three versions of one test; decide which you would want to read in two years.
  • Previous day: Day 73 — Test-Driven Development (labs/sections/programming-with-python/day-073-test-driven-development/).
  • Next day: Day 75 — Static Typing with mypy (labs/sections/programming-with-python/day-075-static-typing-with-mypy/).
  • Week 11 project: the Tested Utility Library (labs/sections/programming-with-python/projects/week-11/), which expects the discipline practised here: a core with its boundaries as parameters, and a suite that is fast because nothing in it reaches the world.

Expected output

FIELDS.md

# Expected output — Day 074 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). Absolute paths
appear as `<repo>`, `<tmp>` and `<python3.14>`; on your machine they are your
real paths.

## Files

- `sample-run.txt` — `examples/demo.py`, `examples/doubles_demo.py` and
  `examples/autospec_demo.py`, each run end to end.
- `pytest-runs.txt` — the six example suites run individually, including the
  two that are **supposed to fail**, with their full failure output.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  exercises still unfinished (49 checks, 0 failures, exit 0).

## What is deterministic and what is not

Almost everything here is byte-identical on every machine. Three things are
not, and each one is a lesson rather than a defect:

| Varies | Where | Why |
| --- | --- | --- |
| The readings and the timings | `demo.py` section 1, and the failure diff in `test_patch_wrong_target.py` | That section calls the real stand-in service on purpose. It is slow and random — which is the entire argument for stubbing it. |
| Mock object ids, e.g. `id='4465297120'` | `autospec_demo.py`, `pytest-runs.txt` | CPython object addresses. Ignore them. |
| Temporary directory names | pytest's `tmp_path` | A fresh directory per test, cleaned up by pytest. |

Everything in `demo.py` sections 2 to 6, all of `doubles_demo.py`, and every
number in the passing suites is identical on every run, because none of that
code can reach a clock, a network, a disk or a random number generator.

## Required behaviour — the passing suites

| Command | Result |
| --- | --- |
| `pytest examples/test_patch_right_target.py -q` | `2 passed`, exit 0 |
| `pytest examples/test_report_v2_fakes.py -q` | `10 passed`, exit 0 |
| `pytest examples/test_model_boundary.py -q` | `14 passed`, exit 0 |
| `pytest examples/test_autospec_naive.py -q` | `1 passed`, exit 0 — **and that is the problem** |

## Required behaviour — the suites that must FAIL

| Command | Result | What it proves |
| --- | --- | --- |
| `pytest examples/test_autospec_specced.py -q` | `2 failed`, exit 1, `AttributeError: Mock object has no attribute 'fetch_radings'` | `create_autospec` catches the typo that a bare `Mock()` waves through |
| `pytest examples/test_patch_wrong_target.py -q` | `1 failed`, exit 1, an `AssertionError` on the report text, in about 0.4 s | patching `sensor_service.fetch_readings` reaches nobody, because `report_v1` bound its own name at import time |

The test suite asserts both of these failures. A suite that could not tell the
naive test from the specced one would be proving nothing at all.

## Required behaviour — the doubles

| Double | Kind | Contract |
| --- | --- | --- |
| `DummyClient` | dummy | `fetch_readings(...)` raises `AssertionError`; it exists to fill a slot |
| `frozen_clock(day)` | stub | returns a zero-argument callable answering `day` |
| `StubSensorClient(readings)` | stub | `fetch_readings(...)` returns a **copy** of `readings`, ignoring both arguments |
| `SpyClock(day)` | spy | `__call__()` returns `day` and increments `.calls` |
| `RecordingSleep()` | spy | `__call__(seconds)` appends to `.waits` and never sleeps |
| `FakeSensorClient(script)` | fake | records `(station, day)` in `.calls`; raises a scripted `Exception` instance or returns a copy of a scripted list; raises `ReadingsUnavailable` when the script runs out |
| `ScriptedModel(script)` | fake | records prompts in `.prompts`; raises or returns the next scripted reply |

## Required behaviour — the report core (`report_v2.py`)

With no files present anywhere:

| Call | Result |
| --- | --- |
| `summarise("ALPHA", DAY, READINGS)` | `DailyReport("ALPHA", DAY, 24, 12.0, 22.0, 17.0)` |
| `summarise("ALPHA", DAY, [])` | raises `ReportError` containing `empty day` |
| `DailyReport(...).render()` first line | `station ALPHA — 2026-04-12` |
| `DailyReport(...).render()` last line | `  mean     17.0` |
| `DailyReport(...).filename()` | `ALPHA-2026-04-12.txt` |
| `build_report(..., clock=SpyClock(DAY), ...)` | the clock is read exactly **once** |
| `build_report(..., client=FakeSensorClient([READINGS]))` | `client.calls == [("ALPHA", "2026-04-12")]` |
| two `ReadingsUnavailable` then readings, `attempts=3` | succeeds; `RecordingSleep().waits == [0.5, 1.0]` |
| three `ReadingsUnavailable`, `attempts=3` | raises `ReportUnavailable` containing `after 3 attempts` |
| `attempts=0` with a `DummyClient` | raises `ReportError` containing `attempts must be at least 1`, **without** calling the client |
| `import report_v2` | imports `datetime` and `dataclasses` only — no `pathlib`, `time`, `random`, `open` or `print` |

`READINGS` is `[12.0, 14.0, 20.0, 22.0] * 6` — 24 values whose mean is exactly
17.0. Check it by hand: `(12 + 14 + 20 + 22) / 4 = 17`.

## Required behaviour — the model boundary (`model_boundary.py`)

| Call | Result |
| --- | --- |
| `build_prompt(report)` | contains `station: ALPHA`, `date: 2026-04-12`, `mean: 17.0`, and `cold, mild, warm, hot` |
| `parse_verdict("label: mild\nconfidence: 0.82\nnote: calm")` | `Verdict("mild", 0.82, "calm")` |
| a reply with a chatty preamble | still parses |
| `Label:  MILD ` | parses to `mild` — case and spacing are tolerated |
| a missing `label` line | `MalformedResponse: missing field(s): label` |
| `label: balmy` | `MalformedResponse: 'balmy' is not one of cold, mild, warm, hot` |
| `confidence: quite` | `MalformedResponse: confidence 'quite' is not a number` |
| `confidence: 1.4` | `MalformedResponse: confidence 1.4 is outside 0.0-1.0` |
| one malformed reply then a good one, `attempts=2` | succeeds; the **same** prompt was resent; `RecordingSleep().waits == [1.0]` |
| two unusable replies, `attempts=2` | raises `ModelError` naming `MalformedResponse` as the last failure |

## The test suite

`bash tests/run_tests.sh` prints one line per check and ends with a count.

| Starter state | Final line | Exit |
| --- | --- | --- |
| exercises unfinished | `49 checks, 0 failure(s).` | 0 |
| all exercises complete | `39 checks, 0 failure(s).` | 0 |

The count goes **down** when you finish, because ten structural checks ("does
`fakes.py` define `SpyClock`?") are replaced by four behavioural ones that hold
your files to the same standard as the reference. Any check failing exits
non-zero, so the suite is usable in continuous integration.

## Platform notes

- **macOS and Linux** — identical. `bash`, `python3` and `mktemp -d` behave the
  same; the suite passes `-p no:cacheprovider` so pytest writes no cache
  directory into your tree.
- **Windows** — use WSL and follow the Linux path. The report header and
  several headings contain an em dash, so a UTF-8 terminal is needed for them
  to render; the numbers and the exit codes are unaffected.
- **Python version** — verified on 3.14.0 only. The parenthesised multi-line
  `with (...)` form used in the patch tests needs Python 3.10 or newer; on an
  older interpreter, use nested `with` statements instead. `create_autospec`
  and `spec=` have behaved as shown here for many releases, but the exact
  wording of the `AttributeError` message differs between versions — assert on
  the exception type, never on the message text.

pytest-runs.txt

$ pytest examples/test_patch_right_target.py -q
..                                                                       [100%]
2 passed in 0.01s
exit: 0

$ pytest examples/test_report_v2_fakes.py -q
..........                                                               [100%]
10 passed in 0.01s
exit: 0

$ pytest examples/test_model_boundary.py -q
..............                                                           [100%]
14 passed in 0.01s
exit: 0

$ pytest examples/test_autospec_naive.py -q
.                                                                        [100%]
1 passed in 0.00s
exit: 0

$ pytest examples/test_autospec_specced.py -q   # EXPECTED to fail
FF                                                                       [100%]
=================================== FAILURES ===================================
________________ test_latest_average_with_an_autospecced_double ________________

    def test_latest_average_with_an_autospecced_double():
        client = create_autospec(SensorClient, instance=True)
        client.fetch_readings.return_value = [10.0, 20.0, 30.0]
    
>       assert latest_average(client, "ALPHA", "2026-04-12") == 20.0
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

examples/test_autospec_specced.py:26: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
examples/typo_under_test.py:18: in latest_average
    readings = client.fetch_radings(station, day)
               ^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <NonCallableMagicMock spec='SensorClient' id='4477642336'>
name = 'fetch_radings'

    def __getattr__(self, name):
        if name in {'_mock_methods', '_mock_unsafe'}:
            raise AttributeError(name)
        elif self._mock_methods is not None:
            if name not in self._mock_methods or name in _all_magics:
>               raise AttributeError("Mock object has no attribute %r" % name)
E               AttributeError: Mock object has no attribute 'fetch_radings'. Did you mean: 'fetch_readings'?

<python3.14>/unittest/mock.py:696: AttributeError
______________ test_a_specced_double_refuses_the_misspelled_name _______________

    def test_a_specced_double_refuses_the_misspelled_name():
        client = create_autospec(SensorClient, instance=True)
    
        # This is the line that a bare Mock() would have accepted in silence.
>       client.fetch_radings.return_value = [1.0]
        ^^^^^^^^^^^^^^^^^^^^

examples/test_autospec_specced.py:33: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <NonCallableMagicMock spec='SensorClient' id='4478452464'>
name = 'fetch_radings'

    def __getattr__(self, name):
        if name in {'_mock_methods', '_mock_unsafe'}:
            raise AttributeError(name)
        elif self._mock_methods is not None:
            if name not in self._mock_methods or name in _all_magics:
>               raise AttributeError("Mock object has no attribute %r" % name)
E               AttributeError: Mock object has no attribute 'fetch_radings'. Did you mean: 'fetch_readings'?

<python3.14>/unittest/mock.py:696: AttributeError
=========================== short test summary info ============================
FAILED examples/test_autospec_specced.py::test_latest_average_with_an_autospecced_double
FAILED examples/test_autospec_specced.py::test_a_specced_double_refuses_the_misspelled_name
2 failed in 0.04s
exit: 1

$ pytest examples/test_patch_wrong_target.py -q   # EXPECTED to fail
F                                                                        [100%]
=================================== FAILURES ===================================
_____________ test_write_daily_report_with_the_wrong_patch_target ______________

tmp_path = PosixPath('<tmp>')

    def test_write_daily_report_with_the_wrong_patch_target(tmp_path):
        with (
            patch("sensor_service.fetch_readings", return_value=READINGS),
            patch("report_v1.datetime") as fake_datetime,
        ):
            fake_datetime.date.today.return_value = FIXED_DAY
            path = report_v1.write_daily_report("ALPHA", str(tmp_path))
    
>       assert path.read_text(encoding="utf-8") == (
            "station ALPHA — 2026-04-12\n"
            "  readings 24\n"
            "  minimum  12.0\n"
            "  maximum  22.0\n"
            "  mean     17.0\n"
        )
E       AssertionError: assert 'station ALPH...an     10.9\n' == 'station ALPH...an     17.0\n'
E         
E         Skipping 42 identical leading characters in diff, use -v to show
E         -  minimum  12.0
E         ?           ^  ^
E         +  minimum  -2.5
E         ?           ^  ^
E         -   maximum  22.0...
E         
E         ...Full output truncated (7 lines hidden), use '-vv' to show

examples/test_patch_wrong_target.py:36: AssertionError
=========================== short test summary info ============================
FAILED examples/test_patch_wrong_target.py::test_write_daily_report_with_the_wrong_patch_target
1 failed in 0.42s
exit: 1

sample-run.txt

$ python3 examples/demo.py
Test the Logic, Stub the World — Day 074

1. What a boundary costs
========================
Two calls to the real service. No sockets are opened — the module
simulates latency and failure — but the two properties are the real ones.
  call 1: 0.41s  24 readings, first three [27.0, 19.5, 23.2]
  call 2: 0.41s  ServiceError: station 'ALPHA' did not answer for 2026-04-12
  slow, and different every time. Multiply by a suite of 200 tests.

2. report_v1 under patch — target matters
=========================================
  report_v1.py says: from sensor_service import fetch_readings
  so the name lives in report_v1's namespace, not sensor_service's.
  patch('report_v1.fetch_readings')
      -> mean     17.0   [0.00s — the stub was used]
  patch('sensor_service.fetch_readings')
      -> mean     14.5   [0.41s — the REAL service was used]
  the second target is not an error. It patches something nobody looks at.

3. report_v2 with hand-written fakes — nothing patched
======================================================
station ALPHA — 2026-04-12
  readings 24
  minimum  12.0
  maximum  22.0
  mean     17.0
  built in 0.05 ms, clock read 1x, calls [('ALPHA', '2026-04-12')]
  the adapter wrote ALPHA-2026-04-12.txt (91 bytes) and did no thinking

4. Retries and failure, without waiting
=======================================
  two failures then success -> mean 17.0
  attempts made: 3   backoff requested: 1.5s
  wall-clock time actually spent: 0.03 ms
  three failures -> ReportUnavailable: no readings for BRAVO on 2026-04-12 after 3 attempts: timeout

5. A model call behind the same boundary
========================================
  the prompt your code builds (this is the testable part):
    | Classify one day of weather station data.
    | Answer with exactly three lines: label, confidence, note.
    | The label must be one of: cold, mild, warm, hot.
    | 
    | station: ALPHA
    | date: 2026-04-12
    | readings: 24
    | minimum: 12.0
    | maximum: 22.0
    | mean: 17.0
  one scripted reply -> Verdict(label='mild', confidence=0.82, note='a calm spring day')
  unavailable, then malformed, then good -> mild after 3 calls
  the same prompt was resent each time: True
  backoff requested [1.0, 2.0]s, cost 0 currency units, took no time

6. The purity proof
===================
  report_v1.py   imports ['datetime', 'pathlib', 'sensor_service']
  report_v2.py   imports ['dataclasses', 'datetime']
  report_v2 imports two ways of writing a value down, and nothing else.
  That is why section 3 needed no patching, no fixtures, and no files.

Done.
exit: 0

$ python3 examples/doubles_demo.py
The five test doubles, on one program
======================================

1. DUMMY — fills an argument slot, must never be used
-----------------------------------------------------
  build_report(attempts=0) -> ReportError: attempts must be at least 1, got 0
  the dummy was never called, which is what proves the early exit

2. STUB — a canned answer, no questions asked, nothing recorded
---------------------------------------------------------------
  StubSensorClient -> mean 17.0
  frozen_clock(2026-04-12) -> 2026-04-12

3. SPY — answers like a stub AND records how it was used
--------------------------------------------------------
  the clock was read 1 time(s)
  the backoff was asked to wait [0.5] second(s) — and waited none

4. MOCK — a double with an EXPECTATION built into it
----------------------------------------------------
  assert_called_once_with('BRAVO', '2026-04-12') -> satisfied
  call_args: call('BRAVO', '2026-04-12')
  the same assertion with the wrong station -> AssertionError: expected call not found.

5. FAKE — a working implementation, simplified
----------------------------------------------
  report mean 17.0, calls recorded [('CHARLIE', '2026-04-12'), ('CHARLIE', '2026-04-12')]
  one fake answered the stub question AND the spy question AND the mock question

The colloquial 'mock' — what unittest.mock actually gives you
-------------------------------------------------------------
  Mock() is a stub when you set return_value : 7
  ...a sequence when you set side_effect      : 1, 2, 3
  ...an error raiser with side_effect=exc     : ReadingsUnavailable: timeout
  ...a spy, because every call is recorded, always
  ...and a mock, the moment you call an assert_ method on it
exit: 0

$ python3 examples/autospec_demo.py
An un-specced Mock accepts everything
=====================================
The real class:
  SensorClient methods: ['fetch_readings', 'stations']
  fetch_readings(self, station: str, day: str) -> list[float]

  bare Mock: an attribute that does not exist
      -> <Mock name='mock.fetch_radings' id='4403431136'>
  bare Mock: a call with an argument the real method has never heard of
      -> <Mock name='mock.fetch_readings()' id='4403433488'>
  bare Mock: what it recorded
      -> call('ALPHA', '2026-04-12', retries=3, nonsense=True)

  Mock(spec=SensorClient): the misspelled attribute
      -> AttributeError: Mock object has no attribute 'fetch_radings'
  Mock(spec=SensorClient): but the SIGNATURE is still unchecked
      -> <Mock name='mock.fetch_readings()' id='4403435504'>

  create_autospec: the misspelled attribute
      -> AttributeError: Mock object has no attribute 'fetch_radings'
  create_autospec: a missing argument
      -> TypeError: missing a required argument: 'day'
  create_autospec: an argument that does not exist
      -> TypeError: got an unexpected keyword argument 'retries'
  create_autospec: the call the real object would accept
      -> [10.0, 20.0, 30.0]

The bug this hides, in three lines
==================================
  typo_under_test.latest_average calls client.fetch_radings(...)
  with a bare Mock (this is what the green test does)
      -> 20.0
  with create_autospec (this is what a good test does)
      -> AttributeError: Mock object has no attribute 'fetch_radings'
  with the REAL SensorClient (this is production)
      -> AttributeError: 'SensorClient' object has no attribute 'fetch_radings'
exit: 0

test-run.txt

$ bash tests/run_tests.sh
Day 074 — Test the Logic, Stub the World

Tool
  ok: pytest is available (pytest 9.1.1)

Suites that must pass
  ok: examples/test_patch_right_target.py passes (patch aimed where the name is looked up)
  ok: examples/test_report_v2_fakes.py passes (10 tests, no patching at all)
  ok: examples/test_model_boundary.py passes (14 tests, no model called)

The autospec demonstration — an un-specced Mock is dangerous
  ok: examples/test_autospec_naive.py PASSES despite the misspelled method
  ok: examples/test_autospec_specced.py FAILS — create_autospec refuses the typo
  ok: the same call against the REAL SensorClient raises AttributeError (production breaks)
  ok: bare Mock invents attributes; spec= refuses them; autospec also checks signatures

The patch-target demonstration — patch where the name is LOOKED UP
  ok: examples/test_patch_wrong_target.py FAILS — patching sensor_service reaches nobody
  ok: patch swaps only the name it names, and puts it back on exit

The tests test something — a broken core must make them fail
  ok: the broken copy really was broken (one line changed)
  ok: the fakes suite FAILS against the broken core

The purity proof — the refactored core needs no patching at all
  ok: examples/report_v2.py imports nothing that does I/O
  ok: examples/model_boundary.py imports nothing that does I/O
  ok: examples/report_v1.py does reach the world (pathlib) — the contrast is real
  ok: a report is built from an empty directory, with no patching
  ok: the rendered report is exact, from an empty directory
  ok: an empty day is refused, from an empty directory
  ok: the clock is read exactly once per report
  ok: two transient failures are retried, and the backoff is 0.5 then 1.0
  ok: exhausting every attempt raises ReportUnavailable
  ok: a dummy client proves the attempts guard fires before any call

The model boundary — deterministic tests of a non-deterministic thing
  ok: a prompt is built and a scripted verdict parsed, from an empty directory
  ok: a malformed reply is retried with the same prompt, and never waits
  ok: an unusable answer after every attempt raises, naming the last failure

The demonstrations run
  ok: examples/demo.py runs end to end and exits 0
  ok: examples/doubles_demo.py runs end to end and exits 0
  ok: examples/autospec_demo.py runs end to end and exits 0

Boundaries
  ok: no example or starter file imports a real network module

Your starter
  ok: fakes.py is valid Python
  ok: report_v1.py is valid Python
  ok: report_v2.py is valid Python
  ok: sensor_service.py is valid Python
  ok: test_report_v1.py is valid Python
  ok: test_report_v2.py is valid Python
  ok: starter/report_v2.py imports nothing that does I/O
Note: starter/ still has unfinished exercises — testing structure only.
  ok: starter/report_v2.py defines DailyReport
  ok: starter/report_v2.py defines ReportError
  ok: starter/report_v2.py defines ReadingsUnavailable
  ok: starter/report_v2.py defines ReportUnavailable
  ok: starter/report_v2.py defines summarise
  ok: starter/report_v2.py defines build_report
  ok: starter/fakes.py defines DummyClient
  ok: starter/fakes.py defines StubSensorClient
  ok: starter/fakes.py defines SpyClock
  ok: starter/fakes.py defines RecordingSleep
  ok: starter/fakes.py defines FakeSensorClient
  ok: starter/fakes.py defines frozen_clock
  ok: both starter test files declare tests to write

49 checks, 0 failure(s).
exit: 0

Source files

examples/adapters.py (1978 bytes)
"""The adapter ring for `report_v2.py` — everything that touches the world.

Three adapters, one per boundary the core refuses to own:

  * `system_clock`      the clock
  * `LiveSensorClient`  the network (here, the stand-in service)
  * `write_report`      the filesystem

Each is a handful of lines with no branching worth testing, which is the point.
The logic lives in the core, where it can be tested for free; the risk lives
out here, where there is almost nothing to get wrong.
"""

from __future__ import annotations

import datetime
import time
from pathlib import Path

from report_v2 import DailyReport, ReadingsUnavailable
from sensor_client import SensorClient
from sensor_service import ServiceError


def system_clock() -> datetime.date:
    """The real clock. The only place in the program that reads today's date."""
    return datetime.date.today()


class LiveSensorClient:
    """Wraps the real client and translates its failure into a domain error.

    This translation is the reason the core never imports `sensor_service`. A
    second client for a different vendor would translate that vendor's errors
    the same way, and `build_report` would not change by a character.
    """

    def __init__(self, client: SensorClient | None = None) -> None:
        self._client = client or SensorClient()

    def fetch_readings(self, station: str, day: str) -> list[float]:
        try:
            return self._client.fetch_readings(station, day)
        except ServiceError as exc:
            raise ReadingsUnavailable(str(exc)) from exc


def write_report(report: DailyReport, out_dir: str) -> Path:
    """Write a finished report to disk. Formatting already happened in the core."""
    path = Path(out_dir) / report.filename()
    path.write_text(report.render() + "\n", encoding="utf-8")
    return path


def real_sleep(seconds: float) -> None:
    """The real backoff. Injected so tests can pass something instantaneous."""
    time.sleep(seconds)
examples/autospec_demo.py (3255 bytes)
"""Why an un-specced Mock is dangerous — shown, not asserted.

    python3 examples/autospec_demo.py

A `Mock()` with no spec answers yes to every question. Ask it for a method that
does not exist and it invents one. Call a method with arguments the real object
would reject and it accepts them. Both behaviours make a test agree with
whatever the code under test happens to do, including the parts that are wrong.

`spec=` fixes the first. `autospec=` / `create_autospec` fixes both.
"""

from __future__ import annotations

from unittest.mock import Mock, create_autospec

from sensor_client import SensorClient
from typo_under_test import latest_average


def show(label: str, fn) -> None:
    try:
        print(f"  {label}\n      -> {fn()}")
    except Exception as exc:
        print(f"  {label}\n      -> {type(exc).__name__}: {exc}")


def main() -> None:
    print("An un-specced Mock accepts everything")
    print("=" * 37)
    print("The real class:")
    print(f"  SensorClient methods: {sorted(m for m in vars(SensorClient) if not m.startswith('_'))}")
    print("  fetch_readings(self, station: str, day: str) -> list[float]")
    print()

    bare = Mock()
    show("bare Mock: an attribute that does not exist", lambda: bare.fetch_radings)
    show(
        "bare Mock: a call with an argument the real method has never heard of",
        lambda: bare.fetch_readings("ALPHA", "2026-04-12", retries=3, nonsense=True),
    )
    show("bare Mock: what it recorded", lambda: bare.fetch_readings.call_args)

    print()
    specced = Mock(spec=SensorClient)
    show("Mock(spec=SensorClient): the misspelled attribute", lambda: specced.fetch_radings)
    show(
        "Mock(spec=SensorClient): but the SIGNATURE is still unchecked",
        lambda: specced.fetch_readings("ALPHA", "2026-04-12", retries=3),
    )

    print()
    auto = create_autospec(SensorClient, instance=True)
    show("create_autospec: the misspelled attribute", lambda: auto.fetch_radings)
    show("create_autospec: a missing argument", lambda: auto.fetch_readings("ALPHA"))
    show(
        "create_autospec: an argument that does not exist",
        lambda: auto.fetch_readings("ALPHA", "2026-04-12", retries=3),
    )
    auto.fetch_readings.return_value = [10.0, 20.0, 30.0]
    show("create_autospec: the call the real object would accept", lambda: auto.fetch_readings("ALPHA", "2026-04-12"))

    print()
    print("The bug this hides, in three lines")
    print("=" * 34)
    print("  typo_under_test.latest_average calls client.fetch_radings(...)")
    show("with a bare Mock (this is what the green test does)", lambda: latest_average(_stubbed_bare(), "ALPHA", "2026-04-12"))
    show("with create_autospec (this is what a good test does)", lambda: latest_average(_stubbed_auto(), "ALPHA", "2026-04-12"))
    show("with the REAL SensorClient (this is production)", lambda: latest_average(SensorClient(), "ALPHA", "2026-04-12"))


def _stubbed_bare() -> Mock:
    client = Mock()
    client.fetch_radings.return_value = [10.0, 20.0, 30.0]
    return client


def _stubbed_auto():
    client = create_autospec(SensorClient, instance=True)
    client.fetch_readings.return_value = [10.0, 20.0, 30.0]
    return client


if __name__ == "__main__":
    main()
examples/demo.py (7693 bytes)
"""The whole lab in one run: the problem, the painful fix, the real fix.

    python3 examples/demo.py

Sections:
  1. what a boundary costs — the real service, timed twice
  2. report_v1 under patch — the right target and the wrong one
  3. report_v2 with hand-written fakes — no patching anywhere
  4. retries and failure, proved with a spy that never waits
  5. a model call behind the same boundary
  6. the purity proof — the core run from an empty directory

Section 1 is the only part of this program whose output changes between runs.
That is not a defect in the demo; it is the defect in the design that the rest
of the lab removes.
"""

from __future__ import annotations

import ast
import datetime
import tempfile
import time
from pathlib import Path
from unittest.mock import patch

import sensor_service
from adapters import write_report
from fakes import FakeSensorClient, RecordingSleep, ScriptedModel, SpyClock, frozen_clock
from model_boundary import ModelUnavailable, build_prompt, classify_day
from report_v2 import ReadingsUnavailable, ReportUnavailable, build_report
from sensor_service import ServiceError

DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6  # 24 values, mean exactly 17.0
GOOD_REPLY = "label: mild\nconfidence: 0.82\nnote: a calm spring day"


def heading(text: str) -> None:
    print()
    print(text)
    print("=" * len(text))


# --- 1 ----------------------------------------------------------------------


def what_a_boundary_costs() -> None:
    heading("1. What a boundary costs")
    print("Two calls to the real service. No sockets are opened — the module")
    print("simulates latency and failure — but the two properties are the real ones.")
    for attempt in (1, 2):
        started = time.perf_counter()
        try:
            readings = sensor_service.fetch_readings("ALPHA", DAY.isoformat())
            outcome = f"{len(readings)} readings, first three {readings[:3]}"
        except ServiceError as exc:
            outcome = f"ServiceError: {exc}"
        elapsed = time.perf_counter() - started
        print(f"  call {attempt}: {elapsed:.2f}s  {outcome}")
    print("  slow, and different every time. Multiply by a suite of 200 tests.")


# --- 2 ----------------------------------------------------------------------


def report_v1_under_patch() -> None:
    heading("2. report_v1 under patch — target matters")
    import report_v1

    print("  report_v1.py says: from sensor_service import fetch_readings")
    print("  so the name lives in report_v1's namespace, not sensor_service's.")

    for target in ("report_v1.fetch_readings", "sensor_service.fetch_readings"):
        with tempfile.TemporaryDirectory() as out_dir:
            started = time.perf_counter()
            try:
                with (
                    patch(target, return_value=READINGS),
                    patch("report_v1.datetime") as fake_datetime,
                ):
                    fake_datetime.date.today.return_value = DAY
                    path = report_v1.write_daily_report("ALPHA", out_dir)
                mean_line = path.read_text(encoding="utf-8").splitlines()[-1].strip()
                outcome = f"{mean_line}"
            except Exception as exc:
                outcome = f"{type(exc).__name__}: {exc}"
            elapsed = time.perf_counter() - started
        verdict = "the stub was used" if outcome == "mean     17.0" else "the REAL service was used"
        print(f"  patch({target!r})")
        print(f"      -> {outcome}   [{elapsed:.2f}s — {verdict}]")
    print("  the second target is not an error. It patches something nobody looks at.")


# --- 3 ----------------------------------------------------------------------


def report_v2_with_fakes() -> None:
    heading("3. report_v2 with hand-written fakes — nothing patched")
    clock = SpyClock(DAY)
    client = FakeSensorClient([READINGS])
    started = time.perf_counter()
    report = build_report("ALPHA", clock=clock, client=client)
    elapsed = time.perf_counter() - started
    print(report.render())
    print(f"  built in {elapsed * 1000:.2f} ms, clock read {clock.calls}x, calls {client.calls}")

    with tempfile.TemporaryDirectory() as out_dir:
        path = write_report(report, out_dir)
        print(f"  the adapter wrote {path.name} ({path.stat().st_size} bytes) and did no thinking")


# --- 4 ----------------------------------------------------------------------


def retries_and_failure() -> None:
    heading("4. Retries and failure, without waiting")
    waits = RecordingSleep()
    client = FakeSensorClient(
        [ReadingsUnavailable("timeout"), ReadingsUnavailable("timeout"), READINGS]
    )
    started = time.perf_counter()
    report = build_report(
        "BRAVO", clock=frozen_clock(DAY), client=client, attempts=3, sleep=waits
    )
    elapsed = time.perf_counter() - started
    print(f"  two failures then success -> mean {report.mean}")
    print(f"  attempts made: {len(client.calls)}   backoff requested: {sum(waits.waits)}s")
    print(f"  wall-clock time actually spent: {elapsed * 1000:.2f} ms")

    waits = RecordingSleep()
    client = FakeSensorClient([ReadingsUnavailable("timeout")] * 3)
    try:
        build_report("BRAVO", clock=frozen_clock(DAY), client=client, attempts=3, sleep=waits)
    except ReportUnavailable as exc:
        print(f"  three failures -> {type(exc).__name__}: {exc}")


# --- 5 ----------------------------------------------------------------------


def the_model_boundary() -> None:
    heading("5. A model call behind the same boundary")
    report = build_report("ALPHA", clock=frozen_clock(DAY), client=FakeSensorClient([READINGS]))
    prompt = build_prompt(report)
    print("  the prompt your code builds (this is the testable part):")
    for line in prompt.splitlines():
        print(f"    | {line}")

    model = ScriptedModel([GOOD_REPLY])
    verdict = classify_day(report, model=model)
    print(f"  one scripted reply -> {verdict}")

    waits = RecordingSleep()
    model = ScriptedModel([ModelUnavailable("rate limited"), "I would rather not say.", GOOD_REPLY])
    verdict = classify_day(report, model=model, attempts=3, sleep=waits)
    print(f"  unavailable, then malformed, then good -> {verdict.label} after {len(model.prompts)} calls")
    print(f"  the same prompt was resent each time: {len(set(model.prompts)) == 1}")
    print(f"  backoff requested {waits.waits}s, cost 0 currency units, took no time")


# --- 6 ----------------------------------------------------------------------


def the_purity_proof() -> None:
    heading("6. The purity proof")
    here = Path(__file__).resolve().parent
    for name in ("report_v1.py", "report_v2.py"):
        tree = ast.parse((here / name).read_text(encoding="utf-8"))
        imported = set()
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                imported.update(alias.name.split(".")[0] for alias in node.names)
            elif isinstance(node, ast.ImportFrom) and node.module:
                imported.add(node.module.split(".")[0])
        imported.discard("__future__")
        print(f"  {name:14s} imports {sorted(imported)}")
    print("  report_v2 imports two ways of writing a value down, and nothing else.")
    print("  That is why section 3 needed no patching, no fixtures, and no files.")


def main() -> None:
    print("Test the Logic, Stub the World — Day 074")
    what_a_boundary_costs()
    report_v1_under_patch()
    report_v2_with_fakes()
    retries_and_failure()
    the_model_boundary()
    the_purity_proof()
    print()
    print("Done.")


if __name__ == "__main__":
    main()
examples/doubles_demo.py (3907 bytes)
"""The five kinds of test double, each demonstrated in a few real lines.

    python3 examples/doubles_demo.py

Gerard Meszaros named these five in *xUnit Test Patterns* (2007). Everyday
speech collapses all of them into the word "mock", which is why so many
conversations about mocking go nowhere: two people say "mock" and mean a stub
and a spy. The names are worth keeping because they answer different questions.
"""

from __future__ import annotations

import datetime
from unittest.mock import Mock, create_autospec

from fakes import (
    DummyClient,
    FakeSensorClient,
    RecordingSleep,
    SpyClock,
    StubSensorClient,
    frozen_clock,
)
from report_v2 import ReadingsUnavailable, build_report
from sensor_client import SensorClient

DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6


def rule(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def main() -> None:
    print("The five test doubles, on one program")
    print("=" * 38)

    rule("1. DUMMY — fills an argument slot, must never be used")
    try:
        build_report("ALPHA", clock=frozen_clock(DAY), client=DummyClient(), attempts=0)
    except Exception as exc:
        print(f"  build_report(attempts=0) -> {type(exc).__name__}: {exc}")
    print("  the dummy was never called, which is what proves the early exit")

    rule("2. STUB — a canned answer, no questions asked, nothing recorded")
    stub = StubSensorClient(READINGS)
    report = build_report("ALPHA", clock=frozen_clock(DAY), client=stub)
    print(f"  StubSensorClient -> mean {report.mean}")
    print(f"  frozen_clock({DAY}) -> {frozen_clock(DAY)()}")

    rule("3. SPY — answers like a stub AND records how it was used")
    clock = SpyClock(DAY)
    waits = RecordingSleep()
    client = FakeSensorClient([ReadingsUnavailable("timeout"), READINGS])
    build_report("ALPHA", clock=clock, client=client, attempts=2, sleep=waits)
    print(f"  the clock was read {clock.calls} time(s)")
    print(f"  the backoff was asked to wait {waits.waits} second(s) — and waited none")

    rule("4. MOCK — a double with an EXPECTATION built into it")
    mock_client = create_autospec(SensorClient, instance=True)
    mock_client.fetch_readings.return_value = READINGS
    build_report("BRAVO", clock=frozen_clock(DAY), client=mock_client)
    mock_client.fetch_readings.assert_called_once_with("BRAVO", "2026-04-12")
    print("  assert_called_once_with('BRAVO', '2026-04-12') -> satisfied")
    print(f"  call_args: {mock_client.fetch_readings.call_args}")
    try:
        mock_client.fetch_readings.assert_called_once_with("CHARLIE", "2026-04-12")
    except AssertionError as exc:
        first_line = str(exc).splitlines()[0]
        print(f"  the same assertion with the wrong station -> AssertionError: {first_line}")

    rule("5. FAKE — a working implementation, simplified")
    fake = FakeSensorClient([ReadingsUnavailable("timeout"), READINGS])
    report = build_report("CHARLIE", clock=frozen_clock(DAY), client=fake, attempts=2)
    print(f"  report mean {report.mean}, calls recorded {fake.calls}")
    print("  one fake answered the stub question AND the spy question AND the mock question")

    rule("The colloquial 'mock' — what unittest.mock actually gives you")
    m = Mock()
    print(f"  Mock() is a stub when you set return_value : {Mock(return_value=7)()}")
    m.side_effect = [1, 2, 3]
    print(f"  ...a sequence when you set side_effect      : {m()}, {m()}, {m()}")
    boom = Mock(side_effect=ReadingsUnavailable("timeout"))
    try:
        boom()
    except ReadingsUnavailable as exc:
        print(f"  ...an error raiser with side_effect=exc     : {type(exc).__name__}: {exc}")
    print("  ...a spy, because every call is recorded, always")
    print("  ...and a mock, the moment you call an assert_ method on it")


if __name__ == "__main__":
    main()
examples/fakes.py (4302 bytes)
"""Hand-written test doubles for the report core. No library involved.

Every one of these is under fifteen lines, and together they replace the whole
of `unittest.mock` for this program. They are also the only doubles in this lab
that a reader can fully understand by reading them — which is the argument the
lesson makes for preferring a fake over a mock whenever a fake is cheap.

One class per kind of double, so you can see the taxonomy in code:

  DummyClient          dummy  — passed to satisfy a signature, never used
  frozen_clock         stub   — canned answer, no recording
  StubSensorClient     stub   — canned answers, no recording
  RecordingSleep       spy    — real-ish behaviour plus a record of the calls
  FakeSensorClient     fake   — a working in-memory implementation
  InMemoryReportStore  fake   — a working in-memory stand-in for the filesystem
"""

from __future__ import annotations

import datetime

from report_v2 import ReadingsUnavailable


class DummyClient:
    """A dummy: it exists to fill an argument slot and must never be called."""

    def fetch_readings(self, station: str, day: str) -> list[float]:
        raise AssertionError("the dummy client was called — the test was wrong about the path taken")


def frozen_clock(day: datetime.date):
    """A stub clock: it always answers `day`, and remembers nothing."""
    return lambda: day


class StubSensorClient:
    """A stub: one canned answer, returned to every caller, forever."""

    def __init__(self, readings: list[float]) -> None:
        self._readings = list(readings)

    def fetch_readings(self, station: str, day: str) -> list[float]:
        return list(self._readings)


class SpyClock:
    """A spy clock: answers like a stub, and records how often it was asked."""

    def __init__(self, day: datetime.date) -> None:
        self.day = day
        self.calls = 0

    def __call__(self) -> datetime.date:
        self.calls += 1
        return self.day


class RecordingSleep:
    """A spy for the backoff: never actually waits, records what it was asked to wait."""

    def __init__(self) -> None:
        self.waits: list[float] = []

    def __call__(self, seconds: float) -> None:
        self.waits.append(seconds)


class FakeSensorClient:
    """A fake: a real, working, in-memory sensor service.

    Give it a script of responses. A list is returned; an exception instance is
    raised. It records every call, so it can also answer the questions a spy
    would answer — which is why one fake usually replaces five mock assertions.
    """

    def __init__(self, script: list) -> None:
        self._script = list(script)
        self.calls: list[tuple[str, str]] = []

    def fetch_readings(self, station: str, day: str) -> list[float]:
        self.calls.append((station, day))
        if not self._script:
            raise ReadingsUnavailable(f"the fake ran out of scripted responses at call {len(self.calls)}")
        item = self._script.pop(0)
        if isinstance(item, Exception):
            raise item
        return list(item)


class ScriptedModel:
    """A fake language model: a scripted reply per call, and a record of prompts.

    Four lines of state stand in for a metered, slow, non-deterministic API. It
    is free, instant and identical on every run — which is exactly what a unit
    test needs, and exactly what the real thing can never be.
    """

    def __init__(self, script: list) -> None:
        self._script = list(script)
        self.prompts: list[str] = []

    def complete(self, prompt: str) -> str:
        self.prompts.append(prompt)
        if not self._script:
            raise AssertionError(f"the scripted model ran out of replies at call {len(self.prompts)}")
        item = self._script.pop(0)
        if isinstance(item, Exception):
            raise item
        return item


class InMemoryReportStore:
    """A fake filesystem: same two operations, a dict instead of a disk."""

    def __init__(self) -> None:
        self.files: dict[str, str] = {}

    def write(self, name: str, body: str) -> str:
        self.files[name] = body
        return name

    def read(self, name: str) -> str:
        if name not in self.files:
            raise FileNotFoundError(name)
        return self.files[name]
examples/model_boundary.py (4285 bytes)
"""A language model behind an injected boundary — the same pattern, one step on.

A model call is not a pure function. It is slow, it costs money per call, and
it returns something different every time even with the same input. You cannot
assert on its text in a unit test, and you should stop trying.

What you CAN test, deterministically and for free, is everything on your side
of the boundary:

  * `build_prompt`  — did you put the right numbers in the right places?
  * `parse_verdict` — do you survive the shapes the model actually returns?
  * `classify_day`  — do you retry the right number of times, and fail cleanly?

So the model arrives as an argument: any object with `complete(prompt) -> str`.
In production that object wraps a real API client. In tests it is four lines of
scripted text. Judging whether the model's answers are any *good* is a separate
job with a separate name — an evaluation suite — and it is not a unit test.
"""

from __future__ import annotations

from dataclasses import dataclass

from report_v2 import DailyReport

LABELS = ("cold", "mild", "warm", "hot")


class ModelError(Exception):
    """Any refusal that belongs to the model-calling layer."""


class ModelUnavailable(ModelError):
    """The model could not be reached or refused to answer. Retryable."""


class MalformedResponse(ModelError):
    """The model answered, but not in the shape this program can use."""


@dataclass(frozen=True)
class Verdict:
    label: str
    confidence: float
    note: str


PROMPT_TEMPLATE = """\
Classify one day of weather station data.
Answer with exactly three lines: label, confidence, note.
The label must be one of: {labels}.

station: {station}
date: {day}
readings: {count}
minimum: {minimum:.1f}
maximum: {maximum:.1f}
mean: {mean:.1f}"""


def build_prompt(report: DailyReport) -> str:
    """Turn a report into the exact text sent to the model. Pure and testable."""
    return PROMPT_TEMPLATE.format(
        labels=", ".join(LABELS),
        station=report.station,
        day=report.day.isoformat(),
        count=report.count,
        minimum=report.minimum,
        maximum=report.maximum,
        mean=report.mean,
    )


def parse_verdict(raw: str) -> Verdict:
    """Parse the model's reply, refusing anything this program cannot use.

    Deliberately forgiving about whitespace, capitalisation and the chatty
    preamble models like to add; deliberately strict about the label and the
    confidence, because those two are the ones that go on to do damage.
    """
    fields: dict[str, str] = {}
    for line in raw.splitlines():
        key, sep, value = line.partition(":")
        if sep:
            fields[key.strip().lower()] = value.strip()

    missing = [k for k in ("label", "confidence", "note") if k not in fields]
    if missing:
        raise MalformedResponse(f"missing field(s): {', '.join(missing)}")

    label = fields["label"].lower()
    if label not in LABELS:
        raise MalformedResponse(f"label {label!r} is not one of {', '.join(LABELS)}")

    try:
        confidence = float(fields["confidence"])
    except ValueError as exc:
        raise MalformedResponse(f"confidence {fields['confidence']!r} is not a number") from exc
    if not 0.0 <= confidence <= 1.0:
        raise MalformedResponse(f"confidence {confidence} is outside 0.0-1.0")

    return Verdict(label=label, confidence=confidence, note=fields["note"])


def classify_day(
    report: DailyReport,
    *,
    model,
    attempts: int = 2,
    sleep=lambda seconds: None,
) -> Verdict:
    """Ask the model to classify a day, retrying a malformed or missing answer.

    `model` is any object with `complete(prompt) -> str`. That one parameter is
    the whole boundary: the network, the cost, and the non-determinism all live
    on the other side of it.
    """
    prompt = build_prompt(report)
    last_reason = ""
    for attempt in range(1, attempts + 1):
        try:
            return parse_verdict(model.complete(prompt))
        except (ModelUnavailable, MalformedResponse) as exc:
            last_reason = f"{type(exc).__name__}: {exc}"
            if attempt < attempts:
                sleep(float(attempt))
    raise ModelError(f"no usable answer after {attempts} attempts — last was {last_reason}")
examples/report_v1.py (2385 bytes)
"""Version 1 — every boundary hard-coded. This is the "before" picture.

`write_daily_report` does four things, and only one of them is logic:

  1. reads the CLOCK          `datetime.date.today()`
  2. calls the NETWORK        `fetch_readings(...)`
  3. computes the summary     <- the only part worth testing
  4. writes the FILESYSTEM    `Path(...).write_text(...)`

There is no way to test step 3 without also performing steps 1, 2 and 4,
because they are welded into the same function. That is what makes this
version untestable without patching.

Note the import style on the next line. `from sensor_service import
fetch_readings` binds the name `fetch_readings` **into this module**. That
single detail decides which patch target works, and it is the thing that trips
everybody up the first time.
"""

from __future__ import annotations

import datetime
from pathlib import Path

from sensor_service import fetch_readings


def summarise(readings: list[float]) -> dict[str, float | int]:
    """Reduce a day of readings to the four numbers the report shows.

    Pure: no clock, no network, no filesystem. Give it a list, get a dict.
    """
    if not readings:
        raise ValueError("cannot summarise an empty day of readings")
    return {
        "count": len(readings),
        "minimum": min(readings),
        "maximum": max(readings),
        "mean": round(sum(readings) / len(readings), 1),
    }


def render(station: str, day: datetime.date, summary: dict[str, float | int]) -> str:
    """Format one report. Also pure."""
    return "\n".join(
        [
            f"station {station} — {day.isoformat()}",
            f"  readings {summary['count']}",
            f"  minimum  {summary['minimum']:.1f}",
            f"  maximum  {summary['maximum']:.1f}",
            f"  mean     {summary['mean']:.1f}",
        ]
    )


def write_daily_report(station: str, out_dir: str) -> Path:
    """Fetch today's readings for one station and write the report to a file.

    Four responsibilities, three of them boundaries. Every test of the third
    one has to pay for the other three.
    """
    day = datetime.date.today()
    readings = fetch_readings(station, day.isoformat())
    body = render(station, day, summarise(readings))
    path = Path(out_dir) / f"{station}-{day.isoformat()}.txt"
    path.write_text(body + "\n", encoding="utf-8")
    return path
examples/report_v2.py (3959 bytes)
"""Version 2 — the boundaries moved out. This is the "after" picture.

Same behaviour as `report_v1.py`, one structural difference: nothing in this
file reads a clock, calls a service, sleeps, or writes a file. The clock, the
client and the sleep arrive as **arguments**, and the report comes back as a
value that somebody else may choose to write down.

Check the imports. `datetime` and `dataclasses` are ways of writing a value
down; neither can touch the outside world. There is no `pathlib`, no `time`,
no `random`, no `open`, no `print`. That is the same purity property Day 70's
domain core had, and it has the same payoff: every rule in this file can be
exercised from an empty directory with no patching at all.
"""

from __future__ import annotations

import datetime
from dataclasses import dataclass


class ReportError(Exception):
    """Any refusal that belongs to the reporting domain."""


class ReadingsUnavailable(ReportError):
    """A client could not supply readings for this attempt.

    The client raises this; `build_report` decides whether to try again. Note
    that the core defines the error it retries on, rather than importing the
    service's own exception type — so a second client for a different service
    can be written without the core learning anything about it.
    """


class ReportUnavailable(ReportError):
    """Every attempt to obtain readings failed."""


@dataclass(frozen=True)
class DailyReport:
    """One day of one station, reduced to the four numbers the report shows."""

    station: str
    day: datetime.date
    count: int
    minimum: float
    maximum: float
    mean: float

    def render(self) -> str:
        return "\n".join(
            [
                f"station {self.station} — {self.day.isoformat()}",
                f"  readings {self.count}",
                f"  minimum  {self.minimum:.1f}",
                f"  maximum  {self.maximum:.1f}",
                f"  mean     {self.mean:.1f}",
            ]
        )

    def filename(self) -> str:
        return f"{self.station}-{self.day.isoformat()}.txt"


def summarise(station: str, day: datetime.date, readings: list[float]) -> DailyReport:
    """Reduce a day of readings to a report. Pure: a list in, a value out."""
    if not readings:
        raise ReportError("cannot summarise an empty day of readings")
    return DailyReport(
        station=station,
        day=day,
        count=len(readings),
        minimum=min(readings),
        maximum=max(readings),
        mean=round(sum(readings) / len(readings), 1),
    )


def build_report(
    station: str,
    *,
    clock,
    client,
    attempts: int = 3,
    sleep=lambda seconds: None,
    backoff_seconds: float = 0.5,
) -> DailyReport:
    """Build today's report for one station.

    `clock`  — a zero-argument callable returning a `datetime.date`.
    `client` — any object with `fetch_readings(station, iso_day) -> list[float]`
               that raises `ReadingsUnavailable` when it cannot answer.
    `sleep`  — a one-argument callable used for backoff between attempts. The
               default does nothing, so tests never wait; the adapter passes
               the real `time.sleep`.

    Every boundary this function needs is a parameter, so a test supplies them
    and nothing has to be patched.
    """
    if attempts < 1:
        raise ReportError(f"attempts must be at least 1, got {attempts}")

    day = clock()
    last_reason = ""
    for attempt in range(1, attempts + 1):
        try:
            readings = client.fetch_readings(station, day.isoformat())
        except ReadingsUnavailable as exc:
            last_reason = str(exc)
            if attempt < attempts:
                sleep(backoff_seconds * attempt)
            continue
        return summarise(station, day, readings)

    raise ReportUnavailable(
        f"no readings for {station} on {day.isoformat()} after {attempts} attempts: {last_reason}"
    )
examples/sensor_client.py (1126 bytes)
"""The object-shaped version of the same boundary.

`report_v2.py` accepts any object with a `fetch_readings(station, day)` method.
This is the real one — it delegates to the slow, flaky stand-in service. It is
also the class used to demonstrate `spec=` and `autospec=`: a test double built
from this class knows which methods exist and what arguments they take, and a
double built from nothing at all knows neither.
"""

from __future__ import annotations

from sensor_service import ServiceError, fetch_readings

__all__ = ["SensorClient", "ServiceError"]


class SensorClient:
    """A client for the sensor service."""

    def __init__(self, base_url: str = "sensors.internal", timeout: float = 5.0) -> None:
        self.base_url = base_url
        self.timeout = timeout

    def fetch_readings(self, station: str, day: str) -> list[float]:
        """Return one reading per hour for ``station`` on ``day`` (ISO date)."""
        return fetch_readings(station, day)

    def stations(self) -> list[str]:
        """Return the station identifiers this client can query."""
        return ["ALPHA", "BRAVO", "CHARLIE"]
examples/sensor_service.py (1737 bytes)
"""A stand-in for a remote sensor service — the "world" this lab must not reach.

Nothing in this file opens a socket, resolves a hostname, or contacts anything.
It exists to be **slow** and **non-deterministic** on purpose, because that is
exactly what a real network call is, and it is the whole reason a unit test
must never reach through a boundary like this one.

Read the two constants below as the definition of a bad test dependency:

    LATENCY_SECONDS = 0.4   ->  a suite of 200 tests would take 80 seconds
    FAILURE_RATE    = 0.25  ->  one test run in four fails for no good reason

A real HTTP client has the same two properties, plus a third: it costs money
when the thing on the other end is a metered API.
"""

from __future__ import annotations

import random
import time

#: How long the "service" pretends to take. A local network round trip is a few
#: milliseconds; a cross-continent HTTPS call is often a few hundred.
LATENCY_SECONDS = 0.4

#: How often the "service" pretends to be unavailable.
FAILURE_RATE = 0.25

#: How many hourly readings a successful call returns.
READINGS_PER_DAY = 24


class ServiceError(Exception):
    """The service was reachable but could not answer the question."""


def fetch_readings(station: str, day: str) -> list[float]:
    """Return one temperature reading per hour for ``station`` on ``day``.

    Slow every time, and unavailable roughly a quarter of the time. The values
    are random, so no test can assert anything about them.
    """
    time.sleep(LATENCY_SECONDS)
    if random.random() < FAILURE_RATE:
        raise ServiceError(f"station {station!r} did not answer for {day}")
    return [round(random.uniform(-4.0, 31.0), 1) for _ in range(READINGS_PER_DAY)]
examples/test_autospec_naive.py (751 bytes)
"""The naive test. It PASSES, and it is worthless.

Run it on its own to watch it go green:

    pytest examples/test_autospec_naive.py -q

The test author read `latest_average`, saw `client.fetch_radings(...)`, and
stubbed exactly that. A bare `Mock()` never objects, so the typo is reproduced
in the test and confirmed by the test. The suite is green; the first production
request raises `AttributeError`.
"""

from unittest.mock import Mock

from typo_under_test import latest_average


def test_latest_average_with_a_bare_mock():
    client = Mock()
    client.fetch_radings.return_value = [10.0, 20.0, 30.0]

    assert latest_average(client, "ALPHA", "2026-04-12") == 20.0
    client.fetch_radings.assert_called_once_with("ALPHA", "2026-04-12")
examples/test_autospec_specced.py (1236 bytes)
"""The same test, written against a specced double. It FAILS, and it is right.

Run it on its own to watch it catch the bug:

    pytest examples/test_autospec_specced.py -q

`create_autospec(SensorClient, instance=True)` builds a double that has exactly
the methods `SensorClient` has, with exactly their signatures. There is no
`fetch_radings` on it, so the test cannot stub one, and the call inside
`latest_average` raises `AttributeError` at the moment the bug happens.

This file is EXPECTED to fail. The lab's test suite asserts that it does — a
suite that could not tell these two files apart would be proving nothing.
"""

from unittest.mock import create_autospec

from sensor_client import SensorClient
from typo_under_test import latest_average


def test_latest_average_with_an_autospecced_double():
    client = create_autospec(SensorClient, instance=True)
    client.fetch_readings.return_value = [10.0, 20.0, 30.0]

    assert latest_average(client, "ALPHA", "2026-04-12") == 20.0


def test_a_specced_double_refuses_the_misspelled_name():
    client = create_autospec(SensorClient, instance=True)

    # This is the line that a bare Mock() would have accepted in silence.
    client.fetch_radings.return_value = [1.0]
examples/test_model_boundary.py (4004 bytes)
"""Testing code that calls a language model, without calling a language model.

    pytest examples/test_model_boundary.py -q

Not one of these tests asserts that the model is clever, or correct, or good.
They assert that YOUR code puts the right numbers in the prompt, survives the
shapes the model actually returns, retries the right number of times, and fails
with a message a human can act on. All of that is deterministic, free, and
finishes in milliseconds.

Whether the model's answers are any good is a real question with a different
answer: an evaluation suite, run deliberately, against a dataset, reporting a
score rather than pass or fail. It is not a unit test and it does not belong in
this file.
"""

import datetime

import pytest
from fakes import RecordingSleep, ScriptedModel
from model_boundary import (
    MalformedResponse,
    ModelError,
    ModelUnavailable,
    Verdict,
    build_prompt,
    classify_day,
    parse_verdict,
)
from report_v2 import DailyReport

REPORT = DailyReport("ALPHA", datetime.date(2026, 4, 12), 24, 12.0, 22.0, 17.0)

GOOD_REPLY = "label: mild\nconfidence: 0.82\nnote: a calm spring day"


# --- the prompt you build ---------------------------------------------------


def test_the_prompt_contains_the_report_s_numbers():
    prompt = build_prompt(REPORT)
    assert "station: ALPHA" in prompt
    assert "date: 2026-04-12" in prompt
    assert "mean: 17.0" in prompt


def test_the_prompt_lists_the_labels_the_parser_will_accept():
    # If these two ever drift apart, every reply becomes malformed.
    assert "cold, mild, warm, hot" in build_prompt(REPORT)


# --- the replies you parse --------------------------------------------------


def test_a_well_formed_reply_parses():
    assert parse_verdict(GOOD_REPLY) == Verdict("mild", 0.82, "a calm spring day")


def test_a_chatty_reply_still_parses():
    # Models add preambles. Your parser meets the model where it is.
    assert parse_verdict("Sure! Here is the answer.\n" + GOOD_REPLY).label == "mild"


def test_capitals_and_stray_spaces_are_tolerated():
    assert parse_verdict("Label:  MILD \nConfidence: 0.5\nNote: x").label == "mild"


@pytest.mark.parametrize(
    "reply, expected_message",
    [
        # `match=` is a regular expression, so the parentheses are escaped.
        ("confidence: 0.9\nnote: x", r"missing field\(s\): label"),
        ("label: balmy\nconfidence: 0.9\nnote: x", "'balmy' is not one of"),
        ("label: mild\nconfidence: quite\nnote: x", "not a number"),
        ("label: mild\nconfidence: 1.4\nnote: x", "outside 0.0-1.0"),
    ],
)
def test_replies_this_program_cannot_use_are_refused(reply, expected_message):
    with pytest.raises(MalformedResponse, match=expected_message):
        parse_verdict(reply)


# --- the retries and failures you own ---------------------------------------


def test_one_good_reply_needs_one_call():
    model = ScriptedModel([GOOD_REPLY])
    assert classify_day(REPORT, model=model).label == "mild"
    assert len(model.prompts) == 1


def test_a_malformed_reply_is_retried():
    model = ScriptedModel(["I would rather not say.", GOOD_REPLY])
    assert classify_day(REPORT, model=model, attempts=2).confidence == 0.82
    assert len(model.prompts) == 2
    assert model.prompts[0] == model.prompts[1]  # the same prompt, resent


def test_an_unavailable_model_is_retried():
    model = ScriptedModel([ModelUnavailable("rate limited"), GOOD_REPLY])
    assert classify_day(REPORT, model=model, attempts=2).label == "mild"


def test_giving_up_says_what_the_last_failure_was():
    model = ScriptedModel(["nonsense", "still nonsense"])
    with pytest.raises(ModelError, match="MalformedResponse"):
        classify_day(REPORT, model=model, attempts=2)


def test_backoff_between_model_attempts_is_recorded_not_waited():
    waits = RecordingSleep()
    model = ScriptedModel(["nonsense", "nonsense", GOOD_REPLY])
    classify_day(REPORT, model=model, attempts=3, sleep=waits)
    assert waits.waits == [1.0, 2.0]
examples/test_patch_right_target.py (2103 bytes)
"""Testing report_v1 with `patch`, aimed at the RIGHT target. It passes.

    pytest examples/test_patch_right_target.py -q

`report_v1.py` says `from sensor_service import fetch_readings`, which binds the
name into `report_v1`'s own namespace at import time. So the name to replace is
`report_v1.fetch_readings` — where it is LOOKED UP, not where it was defined.

Read what it takes to test one four-line function: two patches, a stubbed
module object, a temporary directory, and a comment explaining the second
patch. Every line of that is the price of the boundaries being hard-coded. The
same behaviour in `report_v2.py` needs none of it.
"""

import datetime
from unittest.mock import patch

import report_v1

FIXED_DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6  # 24 values, mean exactly 17.0


def test_write_daily_report_writes_the_expected_file(tmp_path):
    # Patch 1: the network, at the name report_v1 looks up.
    # Patch 2: the clock. report_v1 says `import datetime`, so the whole module
    # object is what it looks up — replacing it wholesale is ugly, and the
    # ugliness is a message about the design, not about unittest.mock.
    with (
        patch("report_v1.fetch_readings", return_value=READINGS) as fetch,
        patch("report_v1.datetime") as fake_datetime,
    ):
        fake_datetime.date.today.return_value = FIXED_DAY
        path = report_v1.write_daily_report("ALPHA", str(tmp_path))

    fetch.assert_called_once_with("ALPHA", "2026-04-12")
    assert path.name == "ALPHA-2026-04-12.txt"
    assert path.read_text(encoding="utf-8") == (
        "station ALPHA — 2026-04-12\n"
        "  readings 24\n"
        "  minimum  12.0\n"
        "  maximum  22.0\n"
        "  mean     17.0\n"
    )


def test_summarise_is_pure_and_needs_no_patching_at_all():
    # The one part of report_v1 that was already testable, because it takes its
    # input as an argument instead of fetching it.
    assert report_v1.summarise(READINGS) == {
        "count": 24,
        "minimum": 12.0,
        "maximum": 22.0,
        "mean": 17.0,
    }
examples/test_patch_wrong_target.py (1503 bytes)
"""The same test with the patch aimed at the WRONG target. It FAILS.

    pytest examples/test_patch_wrong_target.py -q

The only difference from `test_patch_right_target.py` is one string:

    patch("sensor_service.fetch_readings")   <- where the function was DEFINED
    patch("report_v1.fetch_readings")        <- where the name is LOOKED UP

`report_v1` did `from sensor_service import fetch_readings` at import time, so
it holds its own reference. Rebinding the attribute on `sensor_service` after
that point changes nothing `report_v1` can see: the real, slow, random function
is called, and the assertion about the file contents fails (or the real service
raises `ServiceError` first — either way the test does not pass).

This file is EXPECTED to fail. The lab's test suite asserts that it does.
"""

import datetime
from unittest.mock import patch

import report_v1

FIXED_DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6


def test_write_daily_report_with_the_wrong_patch_target(tmp_path):
    with (
        patch("sensor_service.fetch_readings", return_value=READINGS),
        patch("report_v1.datetime") as fake_datetime,
    ):
        fake_datetime.date.today.return_value = FIXED_DAY
        path = report_v1.write_daily_report("ALPHA", str(tmp_path))

    assert path.read_text(encoding="utf-8") == (
        "station ALPHA — 2026-04-12\n"
        "  readings 24\n"
        "  minimum  12.0\n"
        "  maximum  22.0\n"
        "  mean     17.0\n"
    )
examples/test_report_v2_fakes.py (4018 bytes)
"""Testing report_v2 with hand-written fakes. No patching anywhere.

    pytest examples/test_report_v2_fakes.py -q

Compare this file with `test_patch_right_target.py`. There are no `patch`
calls, no module objects replaced, no `tmp_path`, and nothing to undo. The
boundaries are parameters, so a test simply passes different arguments — which
is what "dependency injection" means once you strip the phrase of ceremony.

Every test here runs in microseconds and gives the same answer on every machine
on every day, because nothing in the code under test can reach a clock, a
network, a disk, or a random number generator.
"""

import datetime

import pytest
from fakes import (
    DummyClient,
    FakeSensorClient,
    RecordingSleep,
    SpyClock,
    StubSensorClient,
    frozen_clock,
)
from report_v2 import (
    DailyReport,
    ReadingsUnavailable,
    ReportError,
    ReportUnavailable,
    build_report,
    summarise,
)

DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6  # 24 values, mean exactly 17.0


def test_summarise_reduces_a_day_to_four_numbers():
    report = summarise("ALPHA", DAY, READINGS)
    assert report == DailyReport("ALPHA", DAY, 24, 12.0, 22.0, 17.0)


def test_summarise_refuses_an_empty_day():
    with pytest.raises(ReportError, match="empty day"):
        summarise("ALPHA", DAY, [])


def test_build_report_uses_the_injected_clock_and_client():
    client = StubSensorClient(READINGS)
    report = build_report("ALPHA", clock=frozen_clock(DAY), client=client)
    assert report.day == DAY
    assert report.mean == 17.0
    assert report.render().splitlines()[0] == "station ALPHA — 2026-04-12"


def test_the_client_is_asked_for_the_clock_s_day():
    # A fake records its calls, so it answers the question a spy would answer.
    client = FakeSensorClient([READINGS])
    build_report("BRAVO", clock=frozen_clock(DAY), client=client)
    assert client.calls == [("BRAVO", "2026-04-12")]


def test_the_clock_is_read_exactly_once_per_report():
    # A report that straddled midnight would be half yesterday and half today.
    clock = SpyClock(DAY)
    build_report("ALPHA", clock=clock, client=StubSensorClient(READINGS))
    assert clock.calls == 1


def test_a_transient_failure_is_retried_and_then_succeeds():
    client = FakeSensorClient(
        [ReadingsUnavailable("timeout"), ReadingsUnavailable("timeout"), READINGS]
    )
    report = build_report("ALPHA", clock=frozen_clock(DAY), client=client, attempts=3)
    assert report.mean == 17.0
    assert len(client.calls) == 3


def test_backoff_grows_between_attempts_without_anyone_waiting():
    # The spy proves the backoff schedule. The suite still finishes instantly,
    # because the thing that would have slept is a list append.
    waits = RecordingSleep()
    client = FakeSensorClient(
        [ReadingsUnavailable("timeout"), ReadingsUnavailable("timeout"), READINGS]
    )
    build_report(
        "ALPHA",
        clock=frozen_clock(DAY),
        client=client,
        attempts=3,
        sleep=waits,
        backoff_seconds=0.5,
    )
    assert waits.waits == [0.5, 1.0]


def test_exhausting_every_attempt_raises_a_domain_error():
    client = FakeSensorClient([ReadingsUnavailable("timeout")] * 3)
    with pytest.raises(ReportUnavailable, match="after 3 attempts"):
        build_report("ALPHA", clock=frozen_clock(DAY), client=client, attempts=3)


def test_no_waiting_happens_when_the_first_attempt_succeeds():
    waits = RecordingSleep()
    build_report(
        "ALPHA", clock=frozen_clock(DAY), client=StubSensorClient(READINGS), sleep=waits
    )
    assert waits.waits == []


def test_a_bad_attempts_argument_is_refused_before_anything_is_called():
    # The dummy proves the client is never reached on this path: if it were,
    # the dummy would raise AssertionError instead of ReportError.
    with pytest.raises(ReportError, match="attempts must be at least 1"):
        build_report("ALPHA", clock=frozen_clock(DAY), client=DummyClient(), attempts=0)
examples/typo_under_test.py (792 bytes)
"""Production code with a real bug in it. Read the call carefully.

`SensorClient` has a method called `fetch_readings`. The line below calls
`fetch_radings`. Against the real client that is an `AttributeError` on the
first request in production.

The point of this file is what happens in the *test*: a plain `Mock()` accepts
`fetch_radings` happily, invents an attribute for it, and lets a test that
copies the typo pass. A double built with `spec=` or `create_autospec` knows
which methods the real class has, and refuses.
"""

from __future__ import annotations


def latest_average(client, station: str, day: str) -> float:
    """Return the mean reading for one station on one day."""
    readings = client.fetch_radings(station, day)
    return round(sum(readings) / len(readings), 1)
metadata.yml (1258 bytes)
lesson_id: D074
day: 74
kind: python-program
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-074-mocking-and-testing-boundaries
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/pytest --version
run_commands:
  - python3 examples/demo.py
  - python3 examples/doubles_demo.py
  - python3 examples/autospec_demo.py
  - .venv/bin/pytest examples/test_patch_right_target.py examples/test_report_v2_fakes.py examples/test_model_boundary.py -q
  - '.venv/bin/pytest examples/test_autospec_naive.py -q   # passes, and should not'
  - '.venv/bin/pytest examples/test_autospec_specced.py -q   # fails on purpose'
  - '.venv/bin/pytest examples/test_patch_wrong_target.py -q   # fails on purpose'
  - .venv/bin/pytest starter -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 -> 49 checks, 0 failure(s), exit 0 (39 checks, 0 failure(s) with the starter exercises completed)'
requirements/README.md (3047 bytes)
# Dependencies — Day 074 lab

**One third-party package. `unittest.mock` is not one of them — it ships with
Python.**

## The pinned list

`requirements.txt` contains exactly one line:

```
pytest==9.1.1
```

| Dependency | Version | Licence | Why this lab needs it |
| --- | --- | --- | --- |
| pytest | 9.1.1 | MIT (the `License-Expression` field of the installed distribution's own metadata) | The test runner from Day 71. This lab needs its `tmp_path` fixture, `pytest.raises`, `@pytest.mark.parametrize`, and — most of all — its exit code, because four of this lab's checks assert that a test suite **fails**. |

pytest is free and open source. There is no paid tier, no account, and no
telemetry. Version 9.1.1 was verified on the authoring machine on 2026-07-19.

## What is NOT in the list, and why that matters

`unittest.mock` — `Mock`, `MagicMock`, `patch`, `create_autospec`, `call` — is
part of the Python standard library. It has been since Python 3.3, when Michael
Foord's `mock` package was adopted into the standard library through PEP 417.
You already have it. There is nothing to install and nothing to pin.

The same goes for everything else this lab imports: `datetime`, `dataclasses`,
`ast`, `tempfile`, `time` and `random` are all standard library. The
`sensor_service.py` module that stands in for a network service uses `time` and
`random` to be slow and unpredictable — it opens no socket and contacts nothing.

The lesson's Alternatives section covers four optional libraries — `pytest-mock`
(the `mocker` fixture), `responses` and `requests-mock` for HTTP, and
`freezegun` for time. All four are free and open source, all four install with
`pip`, and **none of them is needed for this lab**. That is deliberate: the
argument the lesson makes is that a hand-written fake usually beats all of
them, and a lab that could not be completed without a mocking library would
undercut its own point.

## Install once

From this lab's 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`. You created your first virtual
environment on Day 43; this is the same procedure, one directory along.
`.venv/` is ignored by version control — never commit it.

## Offline after that

The install needs the network once, to download pytest from the Python Package
Index. After that, **nothing in this lab touches the network at any point** —
not the demos, not the example suites, not `tests/run_tests.sh`. The whole
subject of the day is not reaching the outside world during a test, so a lab
that phoned home would be an odd way to teach it.

If you already have pytest 9.1.1 somewhere else, skip the virtual environment
and point the suite at it:

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

Check your Python first:

```bash
python3 --version
```

Verified on 3.14.0. Python 3.10 or newer is required, because the patch tests
use the parenthesised multi-line `with (...)` form.
requirements/requirements.txt (14 bytes)
pytest==9.1.1
starter/fakes.py (3978 bytes)
"""Exercise 4 — write the test doubles by hand. No library, no magic.

Five small classes and one function. Together they replace every use of
`unittest.mock` in this program, and unlike a mock they can be read.

Delete each `raise NotImplementedError(...)` as you complete that exercise.
"""

from __future__ import annotations

import datetime

from report_v2 import ReadingsUnavailable


class DummyClient:
    """Exercise 4a — a DUMMY. It exists to fill an argument slot.

    Implement `fetch_readings(self, station, day)` so that it raises
    `AssertionError` with a message saying the dummy should not have been
    called. A test passes this where a client is required on a path that must
    never reach the client; if the path is wrong, the dummy says so loudly.
    """

    def fetch_readings(self, station: str, day: str) -> list[float]:
        raise NotImplementedError("Exercise 4a: a dummy that refuses to be used")


def frozen_clock(day: datetime.date):
    """Exercise 4b — a STUB clock. Return a zero-argument callable giving `day`.

    One line. `lambda: day` is a complete answer.
    """
    raise NotImplementedError("Exercise 4b: return a callable that always answers `day`")


class StubSensorClient:
    """Exercise 4c — a STUB client: one canned answer, recorded nothing.

    `__init__(self, readings)` stores a copy. `fetch_readings(station, day)`
    returns a copy of it, ignoring both arguments. Return a copy rather than the
    stored list, so a test that mutates the result cannot corrupt later calls.
    """

    def __init__(self, readings: list[float]) -> None:
        raise NotImplementedError("Exercise 4c: store a copy of the readings")

    def fetch_readings(self, station: str, day: str) -> list[float]:
        raise NotImplementedError("Exercise 4c: return a copy of the stored readings")


class SpyClock:
    """Exercise 4d — a SPY clock: answers like a stub, and counts the questions.

    `__init__(self, day)` sets `self.day = day` and `self.calls = 0`.
    `__call__(self)` increments `self.calls` and returns `self.day`.
    """

    def __init__(self, day: datetime.date) -> None:
        raise NotImplementedError("Exercise 4d: store the day and a call counter")

    def __call__(self) -> datetime.date:
        raise NotImplementedError("Exercise 4d: count the call and return the day")


class RecordingSleep:
    """Exercise 4e — a SPY for the backoff. It records; it never waits.

    `__init__(self)` sets `self.waits = []`. `__call__(self, seconds)` appends
    `seconds` and returns `None`. This is the whole reason a retry test finishes
    in microseconds instead of seconds.
    """

    def __init__(self) -> None:
        raise NotImplementedError("Exercise 4e: start with an empty list of waits")

    def __call__(self, seconds: float) -> None:
        raise NotImplementedError("Exercise 4e: record the requested wait, do not sleep")


class FakeSensorClient:
    """Exercise 4f — a FAKE: a working in-memory sensor service.

    `__init__(self, script)` keeps a copy of `script` and an empty
    `self.calls` list.

    `fetch_readings(self, station, day)`:
      1. append `(station, day)` to `self.calls`;
      2. if the script is empty, raise `ReadingsUnavailable` saying so;
      3. pop the next item; if it is an `Exception` instance, raise it;
      4. otherwise return a copy of it.

    This one class answers the stub question ("what does the client return?"),
    the spy question ("what was it asked?") and the mock question ("was it
    called with the right arguments?") — which is the argument for preferring a
    fake to five mock assertions.
    """

    def __init__(self, script: list) -> None:
        raise NotImplementedError("Exercise 4f: keep a copy of the script and an empty call log")

    def fetch_readings(self, station: str, day: str) -> list[float]:
        raise NotImplementedError("Exercise 4f: record the call, then play the next scripted item")
starter/NOTES.md (2228 bytes)
# Exercise 6 — compare the two approaches

You have now tested the same behaviour twice. Fill this in from your own two
files, in sentences rather than single words. Count things; do not estimate.

## 1. Count what each approach cost

| Question | `starter/test_report_v1.py` (patch) | `starter/test_report_v2.py` (fakes) |
| --- | --- | --- |
| Lines in the test file (`wc -l`) | | |
| Number of `patch(...)` calls | | |
| Number of string targets you had to get exactly right | | |
| Number of pytest fixtures used (`tmp_path`, …) | | |
| Wall-clock time of the file (`pytest --durations=0`) | | |
| Things that must be undone when a test ends | | |

## 2. Answer these in one or two sentences each

**a.** Which file would still pass if somebody changed
`from sensor_service import fetch_readings` to `import sensor_service` at the
top of the module under test, and why?

**b.** Which file would still pass if the report gained a fifth line? Which
would still pass if `write_daily_report` started writing the file before
fetching the readings?

**c.** Your patch test names a module and an attribute in a string. Nothing
checks that string until the test runs. Name one refactor that would silently
turn that test into a test of nothing, and say what you would notice.

**d.** In exercise 5d you asserted on `client.calls`. That is an assertion about
an interaction, not about a result. Say why it is defensible here, and name one
assertion about interactions that you deliberately did NOT write because it
would break on a harmless refactor.

**e.** `report_v2.build_report` takes six parameters where `write_daily_report`
took two. That is a real cost. Argue either side: is the extra signature worth
it for this program, and what size of program would change your answer?

## 3. The design rule you would give a colleague

Write one sentence, in your own words, that tells someone when to reach for
`patch` and when to move the boundary instead. Then write the single sentence
you would put in a code review comment when you see a test with four patches
in it.

## 4. What you could not remove

Some boundaries cannot be injected — name at least one in this lab, say why,
and say what you would do about it instead.
starter/report_v1.py (2385 bytes)
"""Version 1 — every boundary hard-coded. This is the "before" picture.

`write_daily_report` does four things, and only one of them is logic:

  1. reads the CLOCK          `datetime.date.today()`
  2. calls the NETWORK        `fetch_readings(...)`
  3. computes the summary     <- the only part worth testing
  4. writes the FILESYSTEM    `Path(...).write_text(...)`

There is no way to test step 3 without also performing steps 1, 2 and 4,
because they are welded into the same function. That is what makes this
version untestable without patching.

Note the import style on the next line. `from sensor_service import
fetch_readings` binds the name `fetch_readings` **into this module**. That
single detail decides which patch target works, and it is the thing that trips
everybody up the first time.
"""

from __future__ import annotations

import datetime
from pathlib import Path

from sensor_service import fetch_readings


def summarise(readings: list[float]) -> dict[str, float | int]:
    """Reduce a day of readings to the four numbers the report shows.

    Pure: no clock, no network, no filesystem. Give it a list, get a dict.
    """
    if not readings:
        raise ValueError("cannot summarise an empty day of readings")
    return {
        "count": len(readings),
        "minimum": min(readings),
        "maximum": max(readings),
        "mean": round(sum(readings) / len(readings), 1),
    }


def render(station: str, day: datetime.date, summary: dict[str, float | int]) -> str:
    """Format one report. Also pure."""
    return "\n".join(
        [
            f"station {station} — {day.isoformat()}",
            f"  readings {summary['count']}",
            f"  minimum  {summary['minimum']:.1f}",
            f"  maximum  {summary['maximum']:.1f}",
            f"  mean     {summary['mean']:.1f}",
        ]
    )


def write_daily_report(station: str, out_dir: str) -> Path:
    """Fetch today's readings for one station and write the report to a file.

    Four responsibilities, three of them boundaries. Every test of the third
    one has to pay for the other three.
    """
    day = datetime.date.today()
    readings = fetch_readings(station, day.isoformat())
    body = render(station, day, summarise(readings))
    path = Path(out_dir) / f"{station}-{day.isoformat()}.txt"
    path.write_text(body + "\n", encoding="utf-8")
    return path
starter/report_v2.py (4416 bytes)
"""Exercise 3 — move the boundaries out of the function.

Same behaviour as `report_v1.write_daily_report`, one structural difference:
the clock and the client arrive as arguments, and the file writing is somebody
else's job entirely. When you are finished, this module must import NOTHING
that can touch the outside world — no `pathlib`, no `time`, no `random`, no
`open`, no `print`. The two imports already here are all you need.

The test suite checks that purity by parsing this file, and again by running
your code from an empty directory.

Delete each `raise NotImplementedError(...)` as you complete that exercise.
"""

from __future__ import annotations

import datetime
from dataclasses import dataclass


class ReportError(Exception):
    """Any refusal that belongs to the reporting domain."""


class ReadingsUnavailable(ReportError):
    """A client could not supply readings for this attempt. Retryable."""


class ReportUnavailable(ReportError):
    """Every attempt to obtain readings failed."""


@dataclass(frozen=True)
class DailyReport:
    """Exercise 3a — the report as a value.

    Give it six fields, in this order and with these types:
    `station: str`, `day: datetime.date`, `count: int`, `minimum: float`,
    `maximum: float`, `mean: float`. Frozen, as Day 69 and Day 70 taught, so a
    finished report cannot be edited by whatever prints it.
    """

    station: str
    day: datetime.date
    count: int
    minimum: float
    maximum: float
    mean: float

    def render(self) -> str:
        """Exercise 3b — return exactly these five lines, joined by newlines:

            station ALPHA — 2026-04-12
              readings 24
              minimum  12.0
              maximum  22.0
              mean     17.0

        The three numbers use `:.1f`. There is an em dash after the station.
        """
        raise NotImplementedError("Exercise 3b: render the five report lines")

    def filename(self) -> str:
        """Exercise 3c — return `ALPHA-2026-04-12.txt` for the report above."""
        raise NotImplementedError("Exercise 3c: build the filename from station and day")


def summarise(station: str, day: datetime.date, readings: list[float]) -> DailyReport:
    """Exercise 3d — reduce a day of readings to a `DailyReport`.

    Raise `ReportError` on an empty list with a message containing the words
    "empty day". Otherwise return a `DailyReport` whose `count` is the length,
    `minimum` and `maximum` are the extremes, and `mean` is the average rounded
    to one decimal place with `round(..., 1)`.

    Pure: a list in, a value out. No clock, no client, no file.
    """
    raise NotImplementedError("Exercise 3d: reduce readings to a DailyReport")


def build_report(
    station: str,
    *,
    clock,
    client,
    attempts: int = 3,
    sleep=lambda seconds: None,
    backoff_seconds: float = 0.5,
) -> DailyReport:
    """Exercise 3e — the same job as `write_daily_report`, with the seams open.

    `clock`  — a zero-argument callable returning a `datetime.date`.
    `client` — any object with `fetch_readings(station, iso_day) -> list[float]`
               that raises `ReadingsUnavailable` when it cannot answer.
    `sleep`  — a one-argument callable used between attempts. The default does
               nothing, so tests never wait.

    Behaviour to implement:

      1. If `attempts` is less than 1, raise `ReportError` with a message
         containing "attempts must be at least 1". Do this BEFORE calling the
         clock or the client — the dummy in exercise 4 proves you did.
      2. Read the clock exactly once and keep the day. (A report that read the
         clock twice could straddle midnight.)
      3. Up to `attempts` times, call
         `client.fetch_readings(station, day.isoformat())`. On success, return
         `summarise(station, day, readings)`.
      4. On `ReadingsUnavailable`, remember the message. If another attempt is
         left, call `sleep(backoff_seconds * attempt)` — so the waits grow:
         0.5, then 1.0, then 1.5.
      5. If every attempt failed, raise `ReportUnavailable` with a message
         containing "after N attempts" and the last failure's text.

    Note what is NOT here: no `import time`, no `import pathlib`. The signature
    is the design.
    """
    raise NotImplementedError("Exercise 3e: build the report from the injected boundaries")
starter/sensor_service.py (1737 bytes)
"""A stand-in for a remote sensor service — the "world" this lab must not reach.

Nothing in this file opens a socket, resolves a hostname, or contacts anything.
It exists to be **slow** and **non-deterministic** on purpose, because that is
exactly what a real network call is, and it is the whole reason a unit test
must never reach through a boundary like this one.

Read the two constants below as the definition of a bad test dependency:

    LATENCY_SECONDS = 0.4   ->  a suite of 200 tests would take 80 seconds
    FAILURE_RATE    = 0.25  ->  one test run in four fails for no good reason

A real HTTP client has the same two properties, plus a third: it costs money
when the thing on the other end is a metered API.
"""

from __future__ import annotations

import random
import time

#: How long the "service" pretends to take. A local network round trip is a few
#: milliseconds; a cross-continent HTTPS call is often a few hundred.
LATENCY_SECONDS = 0.4

#: How often the "service" pretends to be unavailable.
FAILURE_RATE = 0.25

#: How many hourly readings a successful call returns.
READINGS_PER_DAY = 24


class ServiceError(Exception):
    """The service was reachable but could not answer the question."""


def fetch_readings(station: str, day: str) -> list[float]:
    """Return one temperature reading per hour for ``station`` on ``day``.

    Slow every time, and unavailable roughly a quarter of the time. The values
    are random, so no test can assert anything about them.
    """
    time.sleep(LATENCY_SECONDS)
    if random.random() < FAILURE_RATE:
        raise ServiceError(f"station {station!r} did not answer for {day}")
    return [round(random.uniform(-4.0, 31.0), 1) for _ in range(READINGS_PER_DAY)]
starter/test_report_v1.py (3846 bytes)
"""Exercises 1-2 — test `report_v1.write_daily_report` with `unittest.mock`.

Run these from the lab directory:

    .venv/bin/pytest starter/test_report_v1.py -q

`report_v1.py` is given to you complete and it is deliberately bad: it reads the
clock, calls the service and writes a file inside one function. There is no way
to test it without replacing those three things at run time, so that is what
this file does. Notice how much scaffolding one four-line function costs. That
feeling is the argument for exercise 3.

Delete the `raise NotImplementedError(...)` line from each exercise as you
complete it.
"""

import datetime
from unittest.mock import patch

import report_v1

FIXED_DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6  # 24 values; the mean is exactly 17.0

EXPECTED_FILE = (
    "station ALPHA — 2026-04-12\n"
    "  readings 24\n"
    "  minimum  12.0\n"
    "  maximum  22.0\n"
    "  mean     17.0\n"
)


def test_summarise_needs_no_patching():
    """Exercise 1 — the part that was already testable.

    `summarise` takes its input as an argument, so it has no boundary. Assert
    that `report_v1.summarise(READINGS)` returns the dict with count 24,
    minimum 12.0, maximum 22.0 and mean 17.0.

    Replace the line below with your assertion.
    """
    raise NotImplementedError("Exercise 1: assert on report_v1.summarise(READINGS)")


def test_write_daily_report_writes_the_expected_file(tmp_path):
    """Exercise 2 — the part that needs two patches and a temporary directory.

    Steps:

      a. Patch the NETWORK. `report_v1.py` says
         `from sensor_service import fetch_readings`, so the name to replace is
         `report_v1.fetch_readings` — where it is LOOKED UP, not where it was
         defined. Give it `return_value=READINGS` and keep the handle so you can
         assert on it.
      b. Patch the CLOCK. `report_v1.py` says `import datetime`, so the name it
         looks up is `report_v1.datetime`. Patch that and set
         `fake_datetime.date.today.return_value = FIXED_DAY`.
      c. Call `report_v1.write_daily_report("ALPHA", str(tmp_path))` inside the
         `with` block. `tmp_path` is a pytest fixture (Day 72) giving you a
         fresh directory that pytest cleans up.
      d. After the block, assert three things: the fetch handle was called once
         with `("ALPHA", "2026-04-12")`; the returned path is named
         `ALPHA-2026-04-12.txt`; and its text equals `EXPECTED_FILE`.

    A skeleton to fill in:

        with (
            patch("report_v1.fetch_readings", return_value=READINGS) as fetch,
            patch("report_v1.datetime") as fake_datetime,
        ):
            ...

    Replace the line below with your test.
    """
    raise NotImplementedError("Exercise 2: patch the clock and the service, then assert")


def test_the_wrong_patch_target_does_nothing(tmp_path):
    """Exercise 2b — prove the rule to yourself, once.

    Copy your exercise-2 test, change the first patch target from
    `"report_v1.fetch_readings"` to `"sensor_service.fetch_readings"`, and run
    it. It will take about half a second and then fail (or raise
    `ServiceError`), because `report_v1` holds its own reference to the function
    and never looks at `sensor_service` again.

    Once you have SEEN that, make this test assert the reason, instantly and
    without touching the service. Inside
    `with patch("sensor_service.fetch_readings") as replaced:` assert that
    `sensor_service.fetch_readings is replaced` (the patch worked) while
    `report_v1.fetch_readings is not replaced` (it reached nobody). Import
    `sensor_service` at the top of the file to do this.

    Replace the line below with those two assertions.
    """
    raise NotImplementedError("Exercise 2b: show that the wrong target changes nothing")
starter/test_report_v2.py (3922 bytes)
"""Exercise 5 — test the refactored core with your own doubles.

    .venv/bin/pytest starter/test_report_v2.py -q

Not one `patch` call in this file, not one temporary directory, nothing to
undo. Every boundary is an argument, so a test just passes a different one.

Delete each `raise NotImplementedError(...)` as you complete that exercise.
"""

import datetime

import pytest
from fakes import (
    DummyClient,
    FakeSensorClient,
    RecordingSleep,
    SpyClock,
    StubSensorClient,
    frozen_clock,
)
from report_v2 import (
    DailyReport,
    ReadingsUnavailable,
    ReportError,
    ReportUnavailable,
    build_report,
    summarise,
)

DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6  # 24 values; the mean is exactly 17.0


def test_summarise_reduces_a_day_to_four_numbers():
    """Exercise 5a — assert `summarise("ALPHA", DAY, READINGS)` equals
    `DailyReport("ALPHA", DAY, 24, 12.0, 22.0, 17.0)`. A frozen dataclass
    compares by value, so one `==` covers all six fields."""
    raise NotImplementedError("Exercise 5a: compare the whole report value")


def test_summarise_refuses_an_empty_day():
    """Exercise 5b — use `pytest.raises(ReportError, match="empty day")` around
    `summarise("ALPHA", DAY, [])`."""
    raise NotImplementedError("Exercise 5b: assert the empty-day refusal")


def test_build_report_uses_the_injected_clock_and_client():
    """Exercise 5c — build a report with `clock=frozen_clock(DAY)` and
    `client=StubSensorClient(READINGS)`. Assert `report.day == DAY` and
    `report.mean == 17.0`."""
    raise NotImplementedError("Exercise 5c: build a report from a stub clock and stub client")


def test_the_client_is_asked_for_the_clock_s_day():
    """Exercise 5d — build a report for station `"BRAVO"` with a
    `FakeSensorClient([READINGS])`, then assert
    `client.calls == [("BRAVO", "2026-04-12")]`. The fake answers the question a
    mock's `assert_called_once_with` would answer, and you can read how."""
    raise NotImplementedError("Exercise 5d: assert what the client was asked")


def test_the_clock_is_read_exactly_once():
    """Exercise 5e — pass a `SpyClock(DAY)` and assert `clock.calls == 1` after
    one report. A report that read the clock twice could straddle midnight."""
    raise NotImplementedError("Exercise 5e: assert the clock was read once")


def test_a_transient_failure_is_retried_and_then_succeeds():
    """Exercise 5f — script a fake with
    `[ReadingsUnavailable("timeout"), ReadingsUnavailable("timeout"), READINGS]`,
    call `build_report(..., attempts=3)`, and assert the mean is 17.0 and the
    fake recorded three calls."""
    raise NotImplementedError("Exercise 5f: assert two failures then a success")


def test_backoff_grows_between_attempts_without_anyone_waiting():
    """Exercise 5g — repeat 5f with `sleep=RecordingSleep()` and
    `backoff_seconds=0.5`, then assert the spy recorded `[0.5, 1.0]`. The suite
    still finishes instantly, because the thing that would have slept is a list
    append."""
    raise NotImplementedError("Exercise 5g: assert the backoff schedule without waiting")


def test_exhausting_every_attempt_raises_a_domain_error():
    """Exercise 5h — script three failures and assert
    `pytest.raises(ReportUnavailable, match="after 3 attempts")`."""
    raise NotImplementedError("Exercise 5h: assert the give-up error")


def test_a_bad_attempts_argument_is_refused_before_the_client_is_touched():
    """Exercise 5i — call `build_report(..., client=DummyClient(), attempts=0)`
    inside `pytest.raises(ReportError, match="attempts must be at least 1")`.

    If your `build_report` calls the client before validating `attempts`, the
    dummy raises `AssertionError` and this test fails — which is exactly what a
    dummy is for."""
    raise NotImplementedError("Exercise 5i: assert the guard fires before the client is used")
tests/run_tests.sh (17239 bytes)
#!/usr/bin/env bash
# Tests for the Day 074 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# This suite asserts on pytest's REAL behaviour, including four cases where the
# correct outcome is a failure:
#
#   * an un-specced Mock lets a typo'd method through, so the naive test PASSES
#     while the same call against the real object raises AttributeError;
#   * an autospec'd double refuses the typo, so the honest test FAILS;
#   * a patch aimed at where a function was DEFINED reaches nobody, so that
#     test FAILS, while the same test aimed at where the name is LOOKED UP
#     passes;
#   * a deliberately broken core makes the passing suite fail, which is what
#     proves the suite tests something.
#
# It also proves the refactored core is pure twice over: once by reading its
# imports, once by running it from a directory containing no files at all.
#
# No network at any point, non-interactive, deterministic. Exits 0 only if
# every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

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

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

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

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

run_pytest() {
  # Never write a cache directory into the learner's tree.
  "${pytest_bin}" -p no:cacheprovider -q "$@" >/dev/null 2>&1
}

expect_pass() {
  local label="$1"; shift
  if run_pytest "$@"; then check "${label}" "yes"; else check "${label}" "no"; fi
}

expect_fail() {
  local label="$1"; shift
  if run_pytest "$@"; then check "${label}" "no"; else check "${label}" "yes"; fi
}

# run_pure <label> <python-body>
# Runs a body with the report core importable, from an EMPTY working directory.
# If the body needs a file, a clock, a network or a patch, it fails — which is
# exactly the property being proved.
run_pure() {
  local label="$1" body="$2" empty_dir
  empty_dir="$(mktemp -d "${TMPDIR:-/tmp}/day074-pure.XXXXXX")"
  if (cd "${empty_dir}" && PYTHONPATH="${examples_dir}" python3 -c "
import datetime
from report_v2 import (DailyReport, ReadingsUnavailable, ReportError,
                       ReportUnavailable, build_report, summarise)
from fakes import (DummyClient, FakeSensorClient, RecordingSleep, ScriptedModel,
                   SpyClock, StubSensorClient, frozen_clock)
DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6
${body}
" >/dev/null 2>&1); then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
  rm -rf "${empty_dir}"
}

check_purity() {
  local core_file="$1" label="$2"
  if python3 - "${core_file}" <<'PY' 2>/dev/null
import ast
import sys

BANNED_MODULES = {
    "json", "os", "sys", "io", "pathlib", "shutil", "subprocess", "socket",
    "urllib", "http", "requests", "time", "random", "secrets", "sqlite3",
    "tempfile", "logging", "csv", "pickle",
}
BANNED_CALLS = {"open", "print", "input", "exec", "eval", "compile"}

source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
problems = []
for node in ast.walk(tree):
    if isinstance(node, ast.Import):
        for alias in node.names:
            root = alias.name.split(".")[0]
            if root in BANNED_MODULES:
                problems.append(f"import {alias.name}")
    elif isinstance(node, ast.ImportFrom):
        root = (node.module or "").split(".")[0]
        if root in BANNED_MODULES:
            problems.append(f"from {node.module} import ...")
    elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
        if node.func.id in BANNED_CALLS:
            problems.append(f"{node.func.id}(...)")
sys.exit(1 if problems else 0)
PY
  then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
}

echo "Day 074 — Test the Logic, Stub the World"
echo

# --- 0. the tool ------------------------------------------------------------
echo "Tool"
if "${pytest_bin}" --version >/dev/null 2>&1; then
  check "pytest is available ($("${pytest_bin}" --version 2>&1 | head -1))" "yes"
else
  check "pytest is available" "no"
fi

# --- 1. the suites that must pass -------------------------------------------
echo
echo "Suites that must pass"
expect_pass "examples/test_patch_right_target.py passes (patch aimed where the name is looked up)" \
  "${examples_dir}/test_patch_right_target.py"
expect_pass "examples/test_report_v2_fakes.py passes (10 tests, no patching at all)" \
  "${examples_dir}/test_report_v2_fakes.py"
expect_pass "examples/test_model_boundary.py passes (14 tests, no model called)" \
  "${examples_dir}/test_model_boundary.py"

# --- 2. the autospec demonstration ------------------------------------------
echo
echo "The autospec demonstration — an un-specced Mock is dangerous"
expect_pass "examples/test_autospec_naive.py PASSES despite the misspelled method" \
  "${examples_dir}/test_autospec_naive.py"
expect_fail "examples/test_autospec_specced.py FAILS — create_autospec refuses the typo" \
  "${examples_dir}/test_autospec_specced.py"

if (cd "${examples_dir}" && python3 -c "
from sensor_client import SensorClient
from typo_under_test import latest_average
try:
    latest_average(SensorClient(), 'ALPHA', '2026-04-12')
except AttributeError:
    raise SystemExit(0)
raise SystemExit(1)
" >/dev/null 2>&1); then
  check "the same call against the REAL SensorClient raises AttributeError (production breaks)" "yes"
else
  check "the same call against the REAL SensorClient raises AttributeError (production breaks)" "no"
fi

if (cd "${examples_dir}" && python3 -c "
from unittest.mock import Mock, create_autospec
from sensor_client import SensorClient
Mock().fetch_radings                       # a bare Mock invents it
try:
    Mock(spec=SensorClient).fetch_radings  # spec= refuses it
except AttributeError:
    pass
else:
    raise SystemExit(1)
try:
    create_autospec(SensorClient, instance=True).fetch_readings('ALPHA', '2026-04-12', retries=3)
except TypeError:
    raise SystemExit(0)                    # autospec also checks the signature
raise SystemExit(1)
" >/dev/null 2>&1); then
  check "bare Mock invents attributes; spec= refuses them; autospec also checks signatures" "yes"
else
  check "bare Mock invents attributes; spec= refuses them; autospec also checks signatures" "no"
fi

# --- 3. the patch-target demonstration --------------------------------------
echo
echo "The patch-target demonstration — patch where the name is LOOKED UP"
expect_fail "examples/test_patch_wrong_target.py FAILS — patching sensor_service reaches nobody" \
  "${examples_dir}/test_patch_wrong_target.py"

if (cd "${examples_dir}" && python3 -c "
from unittest.mock import patch
import report_v1, sensor_service
original = report_v1.fetch_readings
with patch('sensor_service.fetch_readings') as replaced:
    assert sensor_service.fetch_readings is replaced      # the patch worked
    assert report_v1.fetch_readings is original           # and reached nobody
with patch('report_v1.fetch_readings') as replaced:
    assert report_v1.fetch_readings is replaced           # this is the target
assert report_v1.fetch_readings is original               # and it was undone
" >/dev/null 2>&1); then
  check "patch swaps only the name it names, and puts it back on exit" "yes"
else
  check "patch swaps only the name it names, and puts it back on exit" "no"
fi

# --- 4. the tests actually test something -----------------------------------
echo
echo "The tests test something — a broken core must make them fail"
broken_dir="$(mktemp -d "${TMPDIR:-/tmp}/day074-broken.XXXXXX")"
cp "${examples_dir}"/*.py "${broken_dir}/"
# Break exactly one line: the mean is now always zero.
sed -i.bak 's|mean=round(sum(readings) / len(readings), 1),|mean=0.0,|' "${broken_dir}/report_v2.py"
rm -f "${broken_dir}/report_v2.py.bak"
if grep -q 'mean=0.0,' "${broken_dir}/report_v2.py"; then
  check "the broken copy really was broken (one line changed)" "yes"
else
  check "the broken copy really was broken (one line changed)" "no"
fi
expect_fail "the fakes suite FAILS against the broken core" "${broken_dir}/test_report_v2_fakes.py"
rm -rf "${broken_dir}"

# --- 5. the purity proof ----------------------------------------------------
echo
echo "The purity proof — the refactored core needs no patching at all"
check_purity "${examples_dir}/report_v2.py" "examples/report_v2.py imports nothing that does I/O"
check_purity "${examples_dir}/model_boundary.py" "examples/model_boundary.py imports nothing that does I/O"

if python3 - "${examples_dir}/report_v1.py" <<'PY' 2>/dev/null
import ast, sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
names = set()
for node in ast.walk(tree):
    if isinstance(node, ast.Import):
        names.update(a.name.split(".")[0] for a in node.names)
    elif isinstance(node, ast.ImportFrom) and node.module:
        names.add(node.module.split(".")[0])
sys.exit(0 if "pathlib" in names else 1)
PY
then
  check "examples/report_v1.py does reach the world (pathlib) — the contrast is real" "yes"
else
  check "examples/report_v1.py does reach the world (pathlib) — the contrast is real" "no"
fi

run_pure "a report is built from an empty directory, with no patching" \
  "r = build_report('ALPHA', clock=frozen_clock(DAY), client=StubSensorClient(READINGS))
assert r == DailyReport('ALPHA', DAY, 24, 12.0, 22.0, 17.0), r"
run_pure "the rendered report is exact, from an empty directory" \
  "r = summarise('ALPHA', DAY, READINGS)
assert r.render().splitlines()[0] == 'station ALPHA — 2026-04-12'
assert r.render().splitlines()[-1] == '  mean     17.0'
assert r.filename() == 'ALPHA-2026-04-12.txt'"
run_pure "an empty day is refused, from an empty directory" \
  "try:
    summarise('ALPHA', DAY, [])
except ReportError as exc:
    assert 'empty day' in str(exc)
else:
    raise SystemExit(1)"
run_pure "the clock is read exactly once per report" \
  "c = SpyClock(DAY)
build_report('ALPHA', clock=c, client=StubSensorClient(READINGS))
assert c.calls == 1, c.calls"
run_pure "two transient failures are retried, and the backoff is 0.5 then 1.0" \
  "w = RecordingSleep()
client = FakeSensorClient([ReadingsUnavailable('t'), ReadingsUnavailable('t'), READINGS])
r = build_report('ALPHA', clock=frozen_clock(DAY), client=client, attempts=3, sleep=w)
assert r.mean == 17.0 and len(client.calls) == 3
assert w.waits == [0.5, 1.0], w.waits"
run_pure "exhausting every attempt raises ReportUnavailable" \
  "client = FakeSensorClient([ReadingsUnavailable('timeout')] * 3)
try:
    build_report('ALPHA', clock=frozen_clock(DAY), client=client, attempts=3)
except ReportUnavailable as exc:
    assert 'after 3 attempts' in str(exc)
else:
    raise SystemExit(1)"
run_pure "a dummy client proves the attempts guard fires before any call" \
  "try:
    build_report('ALPHA', clock=frozen_clock(DAY), client=DummyClient(), attempts=0)
except ReportError as exc:
    assert 'attempts must be at least 1' in str(exc)
else:
    raise SystemExit(1)"

# --- 6. the model boundary --------------------------------------------------
echo
echo "The model boundary — deterministic tests of a non-deterministic thing"
run_pure "a prompt is built and a scripted verdict parsed, from an empty directory" \
  "from model_boundary import build_prompt, classify_day
r = summarise('ALPHA', DAY, READINGS)
assert 'mean: 17.0' in build_prompt(r)
m = ScriptedModel(['label: mild\nconfidence: 0.82\nnote: calm'])
v = classify_day(r, model=m)
assert (v.label, v.confidence) == ('mild', 0.82), v
assert len(m.prompts) == 1"
run_pure "a malformed reply is retried with the same prompt, and never waits" \
  "from model_boundary import classify_day
import time
r = summarise('ALPHA', DAY, READINGS)
w = RecordingSleep()
m = ScriptedModel(['no thanks', 'label: warm\nconfidence: 0.5\nnote: x'])
started = time.perf_counter()
v = classify_day(r, model=m, attempts=2, sleep=w)
assert v.label == 'warm' and len(set(m.prompts)) == 1
assert w.waits == [1.0] and time.perf_counter() - started < 0.1"
run_pure "an unusable answer after every attempt raises, naming the last failure" \
  "from model_boundary import ModelError, classify_day
r = summarise('ALPHA', DAY, READINGS)
try:
    classify_day(r, model=ScriptedModel(['nope', 'still nope']), attempts=2)
except ModelError as exc:
    assert 'MalformedResponse' in str(exc)
else:
    raise SystemExit(1)"

# --- 7. the demonstrations run ----------------------------------------------
echo
echo "The demonstrations run"
for script in demo.py doubles_demo.py autospec_demo.py; do
  if (cd "${examples_dir}" && python3 "${script}" >/dev/null 2>&1); then
    check "examples/${script} runs end to end and exits 0" "yes"
  else
    check "examples/${script} runs end to end and exits 0" "no"
  fi
done

# --- 8. nothing here touches the network ------------------------------------
echo
echo "Boundaries"
# Match real import statements only — the word "socket" appears in prose here.
if grep -REl '^[[:space:]]*(import|from)[[:space:]]+(socket|urllib|http|ftplib|smtplib|requests|httpx)\b' \
    "${examples_dir}" "${starter_dir}" >/dev/null 2>&1; then
  check "no example or starter file imports a real network module" "no"
else
  check "no example or starter file imports a real network module" "yes"
fi

# --- 9. the learner's starter -----------------------------------------------
echo
echo "Your starter"
for f in "${starter_dir}"/*.py; do
  if python3 -c "compile(open('${f}').read(), '${f}', 'exec')" 2>/dev/null; then
    check "$(basename "${f}") is valid Python" "yes"
  else
    check "$(basename "${f}") is valid Python" "no"
  fi
done

check_purity "${starter_dir}/report_v2.py" "starter/report_v2.py imports nothing that does I/O"

if grep -rq 'NotImplementedError' "${starter_dir}"; then
  echo "Note: starter/ still has unfinished exercises — testing structure only."
  for name in DailyReport ReportError ReadingsUnavailable ReportUnavailable; do
    if grep -q "^class ${name}" "${starter_dir}/report_v2.py"; then
      check "starter/report_v2.py defines ${name}" "yes"
    else
      check "starter/report_v2.py defines ${name}" "no"
    fi
  done
  for name in summarise build_report; do
    if grep -q "^def ${name}" "${starter_dir}/report_v2.py"; then
      check "starter/report_v2.py defines ${name}" "yes"
    else
      check "starter/report_v2.py defines ${name}" "no"
    fi
  done
  for name in DummyClient StubSensorClient SpyClock RecordingSleep FakeSensorClient; do
    if grep -q "^class ${name}" "${starter_dir}/fakes.py"; then
      check "starter/fakes.py defines ${name}" "yes"
    else
      check "starter/fakes.py defines ${name}" "no"
    fi
  done
  if grep -q "^def frozen_clock" "${starter_dir}/fakes.py"; then
    check "starter/fakes.py defines frozen_clock" "yes"
  else
    check "starter/fakes.py defines frozen_clock" "no"
  fi
  if grep -q 'def test_' "${starter_dir}/test_report_v1.py" && grep -q 'def test_' "${starter_dir}/test_report_v2.py"; then
    check "both starter test files declare tests to write" "yes"
  else
    check "both starter test files declare tests to write" "no"
  fi
else
  expect_pass "starter/test_report_v1.py passes (your patch tests)" \
    "${starter_dir}/test_report_v1.py"
  expect_pass "starter/test_report_v2.py passes (your fake-based tests)" \
    "${starter_dir}/test_report_v2.py"
  run_pure_starter() {
    local label="$1" body="$2" empty_dir
    empty_dir="$(mktemp -d "${TMPDIR:-/tmp}/day074-starter.XXXXXX")"
    if (cd "${empty_dir}" && PYTHONPATH="${starter_dir}" python3 -c "
import datetime
from report_v2 import DailyReport, ReportError, build_report, summarise
from fakes import StubSensorClient, frozen_clock
DAY = datetime.date(2026, 4, 12)
READINGS = [12.0, 14.0, 20.0, 22.0] * 6
${body}
" >/dev/null 2>&1); then
      check "${label}" "yes"
    else
      check "${label}" "no"
    fi
    rm -rf "${empty_dir}"
  }
  run_pure_starter "your core builds a report from an empty directory, no patching" \
    "r = build_report('ALPHA', clock=frozen_clock(DAY), client=StubSensorClient(READINGS))
assert r == DailyReport('ALPHA', DAY, 24, 12.0, 22.0, 17.0), r
assert r.filename() == 'ALPHA-2026-04-12.txt'
assert r.render().splitlines()[-1] == '  mean     17.0'"
fi

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

Troubleshooting

Troubleshooting — Day 074 lab

Install and tooling

pytest: command not found, or .venv/bin/pytest does not exist. The virtual environment was not created, or was created somewhere else. From this directory:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version    # expect: pytest 9.1.1

FAIL: pytest not found. from tests/run_tests.sh. The runner looks in three places, in order: the PYTEST environment variable, .venv/bin/pytest in this directory, and your PATH. If pytest lives somewhere else, point the runner at it:

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

ModuleNotFoundError: No module named 'report_v2' (or fakes, or sensor_service). pytest puts the test file's own directory on the import path. Run pytest examples/... or pytest starter/... — never copy one test file somewhere else and run it there. If you are using python3 -c directly, set the path yourself: PYTHONPATH=examples python3 -c "...".

SyntaxError on the with ( line in a patch test. The parenthesised multi-line with form needs Python 3.10 or newer. Either upgrade, or rewrite it as nested with statements:

with patch("report_v1.fetch_readings", return_value=READINGS) as fetch:
    with patch("report_v1.datetime") as fake_datetime:
        ...

Patching

The patch appears to do nothing: the test is slow, or the numbers are random. This is the day's headline mistake, and the fix is always the same. You patched where the function was defined; you must patch where the name is looked up.

## report_v1.py contains:  from sensor_service import fetch_readings
patch("sensor_service.fetch_readings")   # reaches nobody
patch("report_v1.fetch_readings")        # this is the one

The tell is the clock: a patched call returns instantly, a real one takes about 0.4 s here. If a "unit test" takes longer than a few milliseconds, something real is still being called.

AttributeError: <module 'report_v1' from '...'> does not have the attribute 'fech_readings'. Good news, actually. patch checks that the attribute exists before replacing it, so a typo in the target string is caught immediately. Fix the spelling. Note the asymmetry that the whole lab is about: patch checks the target name, but a bare Mock() does not check the names you then use on it.

A test passes on its own and fails when the file runs as a whole. A patch escaped its scope. patch as a decorator or in a with block undoes itself on exit even if the test raises; patcher.start() without a matching stop() does not, and leaks into every test after it. If you must use start(), use addfinalizer or a fixture so stop() cannot be skipped — or just use monkeypatch, which undoes everything automatically.

Patching a whole module (patch("report_v1.datetime")) feels wrong. It is. report_v1.py says import datetime, so the only name it looks up is the module itself, and replacing it wholesale is the only way in. That awkwardness is a message about the design of report_v1, not about unittest.mock — and it disappears entirely in report_v2, where the clock is a parameter.

Mocks and doubles

A test is green and the feature is broken. Start by asking what the double was built from. A bare Mock() answers yes to every question: client.fetch_radings exists, and client.fetch_readings("A", "B", retries=3, nonsense=True) is fine. Rebuild it with create_autospec(SensorClient, instance=True) and run again. Watch examples/autospec_demo.py do exactly this.

AttributeError: Mock object has no attribute 'fetch_radings'. The specced double is doing its job. Either the name is misspelled in the code under test — fix the code, that is the bug this catches — or the real class genuinely gained a method and your spec source is out of date.

TypeError: got an unexpected keyword argument 'retries' from an autospec'd double. Only create_autospec and autospec=True check signatures; spec= does not. The call in your code does not match the real method. Again: fix the call.

AttributeError: 'assert_called_once_wiht' is not a valid assertion. Python guards the specific case of a misspelled assert_ method, so this one raises rather than silently passing. It is the only typo a bare Mock() catches — every other misspelled attribute is invented on demand.

StopIteration from a mock with side_effect set to a list. The list ran out: the code under test called it more times than you scripted. Either the retry count is higher than you thought, or the call is inside a loop you forgot about. Count the calls with mock.call_count and script that many.

The refactored core

NotImplementedError: Exercise .... Expected until you finish that exercise. Delete the raise line and write the body described in the docstring above it.

starter/report_v2.py imports nothing that does I/O fails. You added an import the core is not allowed to have — commonly time (for the backoff) or pathlib (for the file). Neither belongs here: the backoff sleeps through the injected sleep parameter, and the file is written by an adapter that receives the finished report. The check parses the file with ast, so a comment mentioning pathlib is not a violation; an actual import is.

your core builds a report from an empty directory fails. Something in your core needs the world. Run the same code yourself to see the real error:

cd "$(mktemp -d)" && PYTHONPATH=/full/path/to/lab/starter python3 -c "
import datetime
from report_v2 import build_report
from fakes import StubSensorClient, frozen_clock
print(build_report('ALPHA', clock=frozen_clock(datetime.date(2026,4,12)),
                   client=StubSensorClient([12.0, 14.0, 20.0, 22.0]*6)).render())
"

The mean is 16.8 or 17 instead of 17.0. Use round(total / count, 1), and format with :.1f in render. The readings [12.0, 14.0, 20.0, 22.0] * 6 average to exactly 17.0 — check it by hand.

The retry test says waits == [0.5] when you expected [0.5, 1.0]. You slept after the final attempt, or not between the second and third. The rule is: sleep only when another attempt is left, and pass backoff_seconds * attempt, where attempt counts from 1.

The dummy raised AssertionError in the attempts=0 test. Your build_report called the client before validating attempts. Move the guard to the first line of the function. This is precisely what a dummy is for.

Things that are not problems

  • examples/test_autospec_specced.py fails. It is supposed to. So is examples/test_patch_wrong_target.py. The test suite asserts both failures.
  • examples/test_autospec_naive.py passes. Also supposed to, and it is the most useful failure in the lab — a green test proving nothing.
  • demo.py prints different readings each run. Section 1 calls the real stand-in service deliberately. Sections 2 to 6 are identical every time.
  • Mock reprs show different id= numbers each run. Object addresses. Ignore them; never assert on them.

Security notes

Security notes — Day 074 lab

  • What the lab does. It runs Python and pytest against files that ship with it. It makes no network connections at any point after the one-time pip install, needs no privileges, and writes nothing into your working directory. Every file it creates goes into a directory made with mktemp -d or pytest's tmp_path and is removed when that check finishes. The runner passes -p no:cacheprovider, so pytest leaves no cache behind either.

  • sensor_service.py contacts nothing. It stands in for a remote service by sleeping and returning random numbers. There is no socket, no hostname, no certificate, and nothing to intercept. Read it — it is forty lines, and the two constants at the top are the whole simulation. A lab about not reaching the outside world during a test would be a strange place to reach the outside world.

  • A mock-heavy suite is a weak security signal. This is the day's real security point and it is worth stating plainly. A test suite in which most dependencies are replaced by doubles proves that your code calls what you believed it should call. It proves nothing about what the real dependency does with those calls. Authentication, authorisation, TLS verification, input sanitisation at a real parser, and rate limiting are exactly the things a mock will happily pretend to have done. Green mock-heavy tests are not evidence that a security control works; a test against the real component, run deliberately, is.

  • Never mock away the check you are relying on. If a test stubs out the function that verifies a signature, checks a permission, or validates a certificate, the test now asserts that the rest of the code behaves correctly when verification succeeds — which is the case nobody was worried about. Write the failing case too: stub the verifier to refuse, and assert that your code refuses with it.

  • Patching is run-time mutation of another module's namespace. patch reaches into an imported module and rebinds an attribute for the duration of a block. That is a large hammer. Two habits keep it safe: always use the decorator or with form, so the original is restored even when the test raises; and never patch anything outside your own test process. A patch that escapes its block silently changes the behaviour of every test that follows, and the failure surfaces somewhere unrelated.

  • Mocking what you do not own is a correctness and a security risk. When you write a double for a third-party client, you encode your beliefs about its behaviour — including your beliefs about which inputs it rejects. If the real library tightened a check in the version you upgraded to, or loosened one, your suite will not notice. The honest fix is a contract test: one set of assertions run against both the real client and your fake, with the real case run deliberately rather than on every commit. Extension exercise 4 has you write one.

  • autospec is a small security control. A double built from the real class cannot accept a method that does not exist or a call the real object would reject, so a rename or a signature change in a dependency shows up as a test failure rather than a production AttributeError. Using create_autospec instead of a bare Mock() costs one line and removes a whole category of green-but-wrong.

  • unittest.mock runs arbitrary code you configure. side_effect accepts a callable, and a Mock will happily call it. Treat test doubles as code: they belong in review, they belong in version control, and you should be as suspicious of a fixture file you did not write as of any other file you did not write.

  • No secrets are needed or used. No API key, no token, no credentials, and no account of any kind. If you extend this lab toward a real service later, keep the key out of the test suite entirely: the whole design here — the client as a parameter — exists so that the tests never need one.