Programming with Python › Testing and Code Quality › Day 71
Hands-on lab — Day 71: Why Test, and pytest Basics
- ← Back to the Day 71 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/
Commands
Setup
cd labs/sections/programming-with-python/day-071-why-test-and-pytest-basics
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version Run
.venv/bin/pytest examples
.venv/bin/pytest examples -q
.venv/bin/pytest examples --collect-only -q
.venv/bin/pytest examples -k reading_time
.venv/bin/pytest examples/failure-demo
.venv/bin/pytest examples/failure-demo -x --tb=short
bash examples/vacuous-demo/prove_it.sh
python3 examples/plain_asserts.py
python3 examples/unittest_demo.py -v
python3 examples/doctest_demo.py
.venv/bin/pytest starter -v Test
bash tests/run_tests.sh File tree
examples/conftest.py examples/doctest_demo.py examples/failure-demo/pytest.ini examples/failure-demo/test_failure_report.py examples/failure-demo/unittest_failure.py examples/plain_asserts.py examples/pytest.ini examples/test_textstats.py examples/textstats.py examples/unittest_demo.py examples/vacuous-demo/prove_it.sh examples/vacuous-demo/pytest.ini examples/vacuous-demo/test_vacuous.py examples/vacuous-demo/textstats.py expected-output/FIELDS.md expected-output/sample-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/conftest.py starter/pytest.ini starter/test_textstats.py starter/textstats.py tests/run_tests.sh troubleshooting.md
Lab README
Day 071 lab — Your First Real Test Suite
Lesson
- Lesson title: Why Test, and pytest Basics
- Day number: 71 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-071-why-test-and-pytest-basics
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-071-why-test-and-pytest-basicswhen the site is running.
Purpose
You are handed a small module called textstats: five functions that split
text into words, count them, average their length, rank the most frequent, and
estimate reading time. It was written quickly, checked by eye on one paragraph
of English prose, and shipped. Two of its five functions are wrong, and
neither is wrong in a way you notice by reading the code once.
Your job is not to read the code until you spot the bugs. Your job is to write tests against the docstrings — which are the specification — and let the tests find them. That distinction is the whole lab. A test whose expected value came out of the code it is testing agrees with the bug and proves nothing.
Around that central task the lab makes four things visible that you cannot learn from prose:
- What a test actually is.
examples/plain_asserts.pyis a working test suite with no test runner at all — bareassertstatements and a process exit code. Run it, break the module, watch it exit 1. pytest is an amplifier for that idea, not a replacement for it. - What a failure report contains.
examples/failure-demo/holds five tests that fail on purpose — a number, a list, a dict, a string, and a missing exception — so you can read five kinds of explanation side by side, and then see the same failures underunittest, which does no assertion rewriting. - That a green suite is a claim, not a fact.
examples/vacuous-demo/holds four tests that pass no matter what the code does, plus one honest test.prove_it.shbreakstop_wordsin a temporary copy and runs both halves. The four stay green. - That testing predates pytest and survives without it.
examples/unittest_demo.pyandexamples/doctest_demo.pyrun the same ideas on the standard library alone, so you can still write tests on a machine where you cannot install anything.
The lab's own test suite is unusual and worth knowing about before you start: it is a test suite about a test suite. Three of its checks copy the code to a temporary directory, break exactly one line, and demand that pytest exits non-zero. That is the check that proves the rest mean anything.
Learning objectives
- Install a pinned third-party tool into a lab-local virtual environment and verify the version you got.
- Write tests as plain functions in arrange-act-assert form, with names that describe the behaviour being pinned rather than the function being called.
- Use
pytest.approxfor a floating-point comparison andpytest.raises(..., match=...)for an expected exception with a pinned message. - Group related tests in a
Test*class and recognise that this adds nothing but organisation — no base class, no setup method, no framework. - Read a pytest failure report: the
>line, theE assert ...explanation, thewhereclause, the short test summary, and the final counts. - Drive a run with
-q,-v,-x,-kand--tb=short, and read--collect-onlyoutput to see exactly what was found. - Name pytest's exit codes from observation, including the one that means "no
tests ran" — and check them yourself with
echo $?. - Prove your own suite is not vacuous by breaking the implementation on purpose and confirming the suite goes red.
Prerequisites
- The Day 71 lesson (read it first — this lab is its exercise).
- Day 70: the domain model whose rules you could not re-check by hand, which is why today exists.
- Day 66: raising exceptions on purpose —
pytest.raisesasserts about exactly that. - Day 43:
python3 -m venv. This lab uses it; it does not re-teach it. - Days 57–62: functions, modules, imports, and reading a traceback.
- A text editor, a terminal, and one network connection for the single install step.
Supported operating systems
- macOS — fully supported (authored and executed 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.9+ and bash).
- Windows — use WSL and follow the Linux path. Without WSL the virtual
environment's tools live at
.venv\Scripts\pytestrather than.venv/bin/pytest,pythonmay be spelledpythonrather thanpython3, andtests/run_tests.shneeds a bash (Git Bash or WSL). The pytest commands themselves are identical.
Hardware requirements
Any computer that runs Python 3. The module under test is under a hundred lines, the reference suite is nineteen tests, and a full run takes about a hundredth of a second. The virtual environment created by the install step is a few tens of megabytes. No GPU, no special memory, no disk to speak of.
Required software
python3, version 3.9 or newer (authored on 3.14.0). Check withpython3 --version.pytest==9.1.1, installed into a lab-local virtual environment by the Installation step below. This is the first lab in the course with a real dependency.bashfor the test runner (preinstalled on macOS and Linux).- Everything else the lab uses —
unittest,doctest,re,math— ships with Python.
Full detail, including the licence and the four packages pytest pulls in, is in
requirements/README.md.
Free and open-source options
Everything here is free and open source. pytest is distributed under the MIT licence, with no paid tier, no account, and no telemetry — and you do not have to take that on trust, because the package will tell you itself once installed:
.venv/bin/pip show pytest
which reports Version: 9.1.1, License-Expression: MIT, and
Requires: iniconfig, packaging, pluggy, pygments.
If you cannot install anything at all — a locked-down machine, no network — the
lab still has something for you. examples/plain_asserts.py,
examples/unittest_demo.py and examples/doctest_demo.py need nothing beyond
Python itself, and between them they demonstrate the entire idea of testing.
The pytest-dependent parts are the reporting and the ergonomics, which are
worth a great deal but are not the concept.
Installation
One command touches the network; everything after it runs offline.
cd labs/sections/programming-with-python/day-071-why-test-and-pytest-basics
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
The last command should print pytest 9.1.1. .venv/ is ignored by version
control repository-wide; never commit it, and delete it whenever you like — it
rebuilds in seconds.
If you already have pytest 9.x elsewhere you do not need a second copy. The
test runner resolves the tool in three steps — an explicit PYTEST override,
then this lab's .venv/bin/pytest, then whatever is on PATH — so both of
these work:
bash tests/run_tests.sh
PYTEST=/full/path/to/pytest bash tests/run_tests.sh
If none of the three finds a pytest, the runner stops with these instructions rather than quietly reporting success on nothing. A suite that skips itself and exits 0 is the exact failure this lab teaches you to distrust.
File structure
day-071-why-test-and-pytest-basics/
├── README.md ← you are here
├── metadata.yml ← machine-readable lab metadata
├── examples/
│ ├── textstats.py ← the FIXED reference module (both bugs repaired)
│ ├── test_textstats.py ← the worked reference suite, 19 tests
│ ├── conftest.py ← SAMPLE constant and the sample_text fixture
│ ├── pytest.ini ← makes examples/ the rootdir
│ ├── plain_asserts.py ← a test suite with no runner at all
│ ├── unittest_demo.py ← the same tests under the stdlib runner
│ ├── doctest_demo.py ← tests living inside docstrings
│ ├── failure-demo/ ← five tests that fail ON PURPOSE
│ │ ├── test_failure_report.py ← number, list, dict, string, missing exception
│ │ ├── unittest_failure.py ← the same failures without assertion rewriting
│ │ └── pytest.ini
│ └── vacuous-demo/ ← four worthless tests and one honest one
│ ├── test_vacuous.py
│ ├── textstats.py
│ ├── prove_it.sh ← breaks the code and proves the point
│ └── pytest.ini
├── starter/
│ ├── textstats.py ← the BUGGY module (exercise 8 repairs it)
│ ├── test_textstats.py ← YOUR suite: exercises 1-8
│ ├── conftest.py ← provided complete
│ └── pytest.ini ← makes starter/ the rootdir
├── tests/
│ └── run_tests.sh ← 47 checks; exits 0 only if all pass
├── expected-output/
│ ├── sample-run.txt ← real captured session, thirteen sections
│ ├── test-run.txt ← real captured run of the test suite
│ └── FIELDS.md ← required counts, exit codes, and behaviour
├── requirements/
│ ├── requirements.txt ← pytest==9.1.1
│ └── README.md ← what it is, why, licence, install
├── troubleshooting.md
└── security.md
Both pytest.ini files exist for a reason explained inside them: they fix the
rootdir, so reports print short paths relative to a directory you chose rather
than one pytest wandered up to.
How to run
From this directory, after the Installation step:
## 1. See the target: the finished reference suite, in full and quiet.
.venv/bin/pytest examples
.venv/bin/pytest examples -q
## 2. See what pytest FOUND, without running anything.
.venv/bin/pytest examples --collect-only -q
## 3. Select a subset by name.
.venv/bin/pytest examples -k reading_time
## 4. Read five deliberate failures, then the same run stopped at the first.
.venv/bin/pytest examples/failure-demo
.venv/bin/pytest examples/failure-demo -x --tb=short
## 5. Watch four green tests certify a broken function.
bash examples/vacuous-demo/prove_it.sh
## 6. Testing without pytest: bare asserts, unittest, doctest.
python3 examples/plain_asserts.py
python3 examples/unittest_demo.py -v
python3 examples/doctest_demo.py
## 7. Your work: open starter/test_textstats.py and complete exercises 1-8.
.venv/bin/pytest starter -v
## 8. Check everything.
bash tests/run_tests.sh
What the commands do
.venv/bin/pytest examples— runs the reference suite and prints the full report: the header naming the platform, versions, rootdir and config file; nineteen dots; and the summary line19 passed in 0.01s. Exit 0..venv/bin/pytest examples -q— the same run without the header. This is what you will actually live in while working..venv/bin/pytest examples --collect-only -q— performs collection and stops. Prints all nineteen test ids in the formtest_textstats.py::TestTopWords::test_returns_exactly_n_items, then19 tests collected. This is the first diagnostic whenever pytest behaves unexpectedly, because half of all confusing sessions turn out to be "it never collected the file you thought it did"..venv/bin/pytest examples -k reading_time— collects all nineteen, then filters to the five whose ids contain that substring:5 passed, 14 deselected. Deselected, not skipped — they never ran.-kalso understandsand,orandnot..venv/bin/pytest examples/failure-demo— five failures on purpose, each showing a different kind of explanation: an arithmetic comparison with awhereclause naming the call, a list diff naming the index that differs, a dict diff naming the key, a character-aligned string diff, andDID NOT RAISE ValueErrorfrom apytest.raisesblock that saw no exception. Ends with the short test summary and exit 1..venv/bin/pytest examples/failure-demo -x --tb=short— the same suite, stopped after the first failure, with one-frame tracebacks.1 failed, exit- Compare the two outputs; the difference is the entire skill of reading pytest.
bash examples/vacuous-demo/prove_it.sh— copies that directory to a temporary one, breakstop_wordswith a singlesed, and runs the suite twice: once with only the four vacuous tests selected (they pass), once with only the honest test (it fails). Nothing in your working tree is touched. The script itself exits 0 only if both halves behave as described.python3 examples/plain_asserts.py— a test suite made of nothing butassert. Printsall plain assertions heldand exits 0. Breakexamples/textstats.pyand it exits 1 with anAssertionErrorand no explanation whatsoever — which is precisely the gap assertion rewriting fills.python3 examples/unittest_demo.py -v— the same six behaviours under the standard library's runner: aTestCasesubclass,assertEqual,assertAlmostEqual,assertRaises.Ran 6 tests,OK, exit 0. Nothing was installed to make this work.python3 examples/doctest_demo.py— runs the examples embedded in two docstrings and reportsdoctest: 8 examples attempted, 0 failed.python3 -m doctest examples/doctest_demo.pydoes the same and prints nothing at all on success..venv/bin/pytest starter -v— your suite. Before you write anything it reports1 passed, 9 skippedand exits 0, because each unfinished exercise ends in apytest.skip(...)line. Replace those with real assertions as you go.bash tests/run_tests.sh— the lab's own 47 checks: the tool version, the reference suite, collection and selection, failure reporting, exit codes, the break-it-on-purpose proofs, the starter, and the three standard-library runners. Exits 0 only if every check passes.
Expected output
The full captured session — thirteen sections covering every command above,
all four exit codes, and the collision you get from running two directories at
once — is in expected-output/sample-run.txt.
The heart of it:
$ pytest examples
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: <repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collected 19 items
examples/test_textstats.py ................... [100%]
============================== 19 passed in 0.01s ==============================
exit: 0
and the failure that explains why pytest needs no assertEqual:
def test_a_simple_number_comparison():
> assert add(1, 2) == 3
E assert 4 == 3
E + where 4 = add(1, 2)
examples/failure-demo/test_failure_report.py:20: AssertionError
and the demonstration the whole lab is built around:
$ bash examples/vacuous-demo/prove_it.sh
Breaking top_words: ranked[:n] becomes ranked[: n - 1]
--- the four vacuous tests, on the BROKEN module ---
.... [100%]
4 passed, 1 deselected in 0.00s
exit: 0
--- the one honest test, on the same BROKEN module ---
F [100%]
E AssertionError: assert [('a', 3)] == [('a', 3), ('b', 2)]
E Right contains one more item: ('b', 2)
1 failed, 4 deselected in 0.01s
exit: 1
Point made: four green tests, one broken function, zero warnings.
Two things in the captures are sanitized and nothing else is: absolute paths
appear as <repo>, and the interpreter path pytest prints under -v appears
as <venv>/bin/python3.14. The plugins: line lists whatever happens to be
installed alongside pytest — a clean lab .venv built from
requirements.txt shows no plugins: line at all. Every count, every dot, and
every exit code is exactly what the command printed.
Nothing in this lab reads the clock, the network, or a random number, so the
same commands produce the same counts on any machine with the same pytest
version. expected-output/FIELDS.md lists the
required result of every command, pytest's six exit codes, the full
specification of the module under test, and the two bugs stated plainly.
Validation steps
.venv/bin/pytest --versionprintspytest 9.1.1..venv/bin/pytest examples -qreports19 passedand exits 0..venv/bin/pytest examples --collect-only -qends with19 tests collected, and exactly five of the ids contain::TestTopWords::..venv/bin/pytest examples -k reading_timereports5 passed, 14 deselected..venv/bin/pytest examples/failure-demoexits 1 and its report containsE assert 4 == 3,At index 4 diff:, andDID NOT RAISE ValueError.- In an empty directory,
pytest . -qprintsno tests ranand exits 5. Check it yourself withecho $?— this is the number that makes naive build scripts ship untested code. bash examples/vacuous-demo/prove_it.shends withPoint made: four green tests, one broken function, zero warnings.and exits 0.- With exercises 1–7 written but
starter/textstats.pynot yet repaired,.venv/bin/pytest starter -qFAILS — exercise 4 and at least oneTestTopWordsmethod. That failure is the lab working, not you failing. - With exercise 8 finished,
.venv/bin/pytest starter -qreports10 passedand exits 0. - Re-break one line of
starter/textstats.py, run again, and confirm the suite goes red and exits 1. Then undo it. Do not skip this step — it is the only evidence that step 9 meant anything. bash tests/run_tests.shends with47 checks, 0 failure(s).and exits 0.
Tests
bash tests/run_tests.sh
Expected final line: 47 checks, 0 failure(s)., and the process exits 0. A
full captured run is in
expected-output/test-run.txt. The checks are
grouped into nine sections, printed as they run:
| Section | What it proves |
|---|---|
| 1. The tool itself | pytest --version reports a pytest |
| 2. The reference suite passes | pytest examples exits 0 and reports 19 passed |
| 3. Collection | four specific test ids are collected, exactly 19 are found, TestTopWords contributes 5, and two different -k expressions each select 5 of 19 |
| 4. Failure reporting | the demo exits 1, assertion rewriting shows assert 4 == 3 and its where line, a list diff names the index, pytest.raises reports a missing exception, the short summary lists every failure, and -x stops at the first |
| 5. Exit codes | a run that collects nothing exits 5, not 0 |
| 6. The suite tests something | the control run is green; one sed edit to word_count makes it fail; the reference suite rejects the buggy starter with exactly 4 failed, 15 passed; both bugs are still present in starter/ and both repairs present in examples/; and the vacuous demonstration behaves as claimed |
| 7. The starter | it runs before you start — 1 passed, 9 skipped, 10 collected, exit 0 |
| 8. Without pytest | bare asserts pass and fail correctly, unittest passes, its failure demo reports both sides while a bare assert inside it reports nothing, and doctest runs all 8 documented examples |
| 9. Determinism | no network, clock or randomness anywhere in examples/ or starter/ |
Section 6 is the one to read before you run it. Its checks copy code into a
directory made with mktemp -d, break exactly one line with sed, and demand
a non-zero exit. A suite that stays green when the code is wrong is worse
than no suite at all, and these checks are the lab holding itself to the
standard it teaches. Nothing inside the lab directory is ever modified, so a
failing run cannot corrupt your work.
The runner needs no network and asks no questions, so it is safe to run in
continuous integration. It resolves pytest through PYTEST, then
.venv/bin/pytest, then PATH, and stops loudly if it finds none.
Cleanup
## Remove Python's bytecode cache (always safe).
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
## Optional: remove the installed pytest. It rebuilds in seconds.
rm -rf .venv
## Optional: reset your work on the exercises.
git checkout -- starter/
The lab writes nothing else. prove_it.sh and the test runner create their own
directories with mktemp -d and delete each one as that check finishes. This
lab disables pytest's cache via addopts = -p no:cacheprovider in both
pytest.ini files, so no .pytest_cache directory appears.
Troubleshooting
See troubleshooting.md for the full list — every symptom
in it was produced on purpose while building the lab, and the messages are
quoted from real runs. The ones you are most likely to meet:
pytest: command not found (you are not using .venv/bin/pytest);
no tests ran with exit code 5 (collection found nothing — wrong path, or a
file not named test_*.py); ModuleNotFoundError: No module named 'textstats'
(you ran pytest from inside starter/ or from the repository root instead of
from this directory); fixture 'sample_text' not found (the parameter name and
the fixture name must match exactly — the match is by name and nothing else);
ERROR collecting test_textstats.py with import file mismatch (you ran
pytest starter examples, and the two identically named files collided — run
one directory at a time); and ZeroDivisionError from
average_word_length(""), which is not a problem but bug 1, caught.
Security notes
See security.md. Short version, and the security lesson of the
day: test code is code. pytest imports every file it collects, and
importing runs the module body — so a file named test_anything.py in a
directory you point pytest at executes with your privileges before a single
assertion is evaluated. conftest.py is imported automatically, without any
file naming it, which makes it the quietest place in a repository to hide code
that runs on every invocation; read its diff as carefully as you read the
source diff. Beyond that: install into a virtual environment and never with
sudo; pin versions and read the package name you are typing, because
typo-squatting is an ordinary attack; keep real credentials out of test files,
since a failing assertion prints both sides of the comparison straight into a
build log; and remember that python -O strips assert statements entirely,
which is fine for tests and disqualifying for runtime validation of untrusted
input.
Extension exercises
- Write your own runner. In about forty lines of standard-library Python
and no pytest, walk a directory, import every
test_*.py, call every module-leveltest_*callable inside atry/except AssertionError, print a.or anF, print the failures and a count, and exit 0 only if all passed. Run it againstexamples/. Two things will happen: the fixture tests will fail because you did not implement fixtures, and every failure will sayAssertionErrorand nothing else because you did not rewrite the assertions. Write down in two sentences what fixing the second would take. - Find a third bug. The specification in the docstrings is more detailed
than the reference suite checks. Read
examples/textstats.pyagainst its own docstrings and find a behaviour no test currently pins — then write the test, and decide whether the code or the docstring should change. - Break each function in turn. For each of the five functions, make one
small edit to
examples/textstats.pyin a copy, run.venv/bin/pytest . -q --tb=line, and record which tests caught it. Any function you can break without turning the suite red has a coverage hole with your name on it. - Make a test flaky on purpose. Add a test that asserts something about
the current second, run it in a loop with
for i in $(seq 60); do pytest -q -k flaky || break; done, and watch it fail eventually. Then remove it. Knowing what a flaky failure looks and feels like is worth more than being warned about one. - Compare the runners honestly. Write the same three tests three times —
as bare asserts, under
unittest, and under pytest — break the module, and put the three failure reports side by side. Time yourself reading each one. That number is what pytest is actually selling.
Navigation
- Previous day: Day 70 — Modeling a Domain with Objects
(
labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects/). - Next day: Day 72 — fixtures and parametrisation, which turn the
sample_textfixture you used today into a subject of its own (labs/sections/programming-with-python/). - Week 11 project: the Tested Utility Library
(
labs/sections/programming-with-python/projects/week-11/). It applies this week's tools — pytest, fixtures, mocking, type checking and linting — to a library you build and keep.
Expected output
FIELDS.md
# Expected output — Day 071 lab
These are real captured runs from the authoring machine (macOS 26.5.1, Apple
Silicon, Python 3.14.0, pytest 9.1.1, bash 3.2.57, 2026-07-19). Nothing in this
lab reads the clock, the network, or a random number, so the same commands
produce the same counts on any machine with the same pytest version.
## Files
- `sample-run.txt` — thirteen captured sections: the reference suite in full,
quiet and verbose; collection only; `-k` selection; a full failure report;
the same failures under `--tb=short`, `--tb=line` and `-x`; the same three
failures under `unittest`; the starter before you write anything; the four
exit codes; a one-line break making the suite fail; the vacuous-test
demonstration; `unittest`, `doctest` and bare `assert` runs; and the
collection error you get from running two directories at once.
- `test-run.txt` — a full run of `bash tests/run_tests.sh`: 47 checks, 0
failures, exit 0.
## Two sanitizations, so you know what you are looking at
1. Absolute paths appear as `<repo>`. On your machine they are your real
repository path.
2. In `-v` output pytest prints the interpreter it is running under. That path
appears as `<venv>/bin/python3.14`. On your machine, having followed
`requirements/README.md`, it is this lab's `.venv/bin/python3.14`.
Nothing else is edited. Every count, every dot, every exit code below is what
the command actually printed.
## What varies between machines, and what does not
| Line | Varies? | Why |
| --- | --- | --- |
| `platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0` | Yes | Your platform, Python and pluggy versions. `pytest-9.1.1` must match, because `requirements.txt` pins it |
| `rootdir: .../examples` | Path only | The rootdir is always the directory holding the `pytest.ini` that was found |
| `configfile: pytest.ini` | No | This lab ships that file on purpose |
| `plugins: cov-7.1.0, anyio-4.14.2` | Yes | Whatever plugins happen to be installed alongside pytest. A clean lab `.venv` shows no plugins line at all |
| `19 passed in 0.01s` | Duration only | The count 19 is fixed; the seconds are not |
| Every exit code | No | Exit codes are a contract, not a report |
## Required counts and exit codes
Your finished work must reproduce exactly this:
| Command | Result |
| --- | --- |
| `pytest examples` | `19 passed`, exit `0` |
| `pytest examples --collect-only -q` | `19 tests collected`, exit `0` |
| `pytest examples -k reading_time` | `5 passed, 14 deselected`, exit `0` |
| `pytest examples -k "top_words or TestTopWords"` | `5 passed, 14 deselected`, exit `0` |
| `pytest examples/failure-demo` | `5 failed`, exit `1` |
| `pytest examples/failure-demo -x` | `1 failed`, exit `1` |
| `pytest examples/vacuous-demo` | `5 passed`, exit `0` (all five pass; four of them are worthless anyway) |
| `pytest starter` (exercises unfinished) | `1 passed, 9 skipped`, exit `0` |
| `pytest starter --collect-only -q` | `10 tests collected` |
| `pytest .` in an empty directory | `no tests ran`, exit **`5`** |
| `pytest starter examples` | collection error, exit `2` — two files named `test_textstats.py` |
| `python3 examples/plain_asserts.py` | `all plain assertions held`, exit `0` |
| `python3 examples/unittest_demo.py` | `Ran 6 tests`, `OK`, exit `0` |
| `python3 examples/doctest_demo.py` | `doctest: 8 examples attempted, 0 failed`, exit `0` |
| `python3 -m doctest examples/doctest_demo.py` | no output at all, exit `0` |
| `python3 examples/failure-demo/unittest_failure.py` | `FAILED (failures=3)`, exit `1` |
| `bash examples/vacuous-demo/prove_it.sh` | `4 passed` then `1 failed`, exit `0` |
| `bash tests/run_tests.sh` | `47 checks, 0 failure(s).`, exit `0` |
## pytest's exit codes, which is what CI reads
| Code | Meaning | Seen in this lab |
| --- | --- | --- |
| 0 | All collected tests passed | `pytest examples` |
| 1 | Tests ran and at least one failed | `pytest examples/failure-demo` |
| 2 | The run was interrupted — including a collection error | `pytest starter examples` |
| 3 | An internal error happened while running tests | not triggered here |
| 4 | pytest was used wrongly on the command line | not triggered here |
| 5 | No tests were collected | `pytest .` in an empty directory |
Code 5 is the one that matters most in practice, and the reason a build script
must never be written as "if pytest did not print FAILED, ship it". A typo in a
path collects nothing, prints no failure, and returns 5. Anything that treats
"not 1" as success ships on a suite that never ran.
## Required behaviour of the module under test
The docstrings in `starter/textstats.py` are the specification. Restated as a
table, with every number checkable by hand:
| Call | Result |
| --- | --- |
| `words("The cat. The hat!")` | `['the', 'cat', 'the', 'hat']` |
| `words("don't stop")` | `["don't", 'stop']` — an apostrophe stays inside a word |
| `words("3 apples & 4 pears")` | `['apples', 'pears']` — digits and symbols separate |
| `words("")` | `[]` |
| `word_count(SAMPLE)` | `12` (the sample sentence, counted by hand in `conftest.py`) |
| `word_count("")` | `0` |
| `average_word_length("the cat")` | `3.0` |
| `average_word_length(SAMPLE)` | `3.83` — 46 characters over 12 words |
| `average_word_length("")` | `0.0` — **bug 1**: the starter raises `ZeroDivisionError` |
| `top_words("a b a c a b", 2)` | `[('a', 3), ('b', 2)]` — **bug 2**: the starter returns `[('a', 3)]` |
| `top_words("a b a c a b", 9)` | `[('a', 3), ('b', 2), ('c', 1)]` |
| `top_words("a b a c a b", 0)` | `[]` — the starter returns two items, because `ranked[:-1]` |
| `top_words("", 5)` | `[]` |
| `top_words(SAMPLE, 3)` | `[('the', 3), ('dog', 2), ('barks', 1)]` — ties broken alphabetically |
| `reading_time_minutes("hello there")` | `1` |
| `reading_time_minutes("word " * 500)` | `3` — 500/200 is 2.5, always rounded up |
| `reading_time_minutes("word " * 500, 100)` | `5` |
| `reading_time_minutes("")` | `0` |
| `reading_time_minutes(SAMPLE, 0)` | raises `ValueError("words_per_minute must be positive, got 0")` |
## The two bugs, stated plainly
1. `average_word_length` divides by `len(found)` without checking whether
`found` is empty, so empty text raises `ZeroDivisionError` instead of
returning `0.0`. Caught by
`test_average_word_length_of_empty_text_is_zero`.
2. `top_words` slices `ranked[: n - 1]`, off by one, because a slice already
stops *before* its end index. Asking for two gives one; asking for zero
gives everything but the last. Caught by three of the five `TestTopWords`
tests.
Running the reference suite against the buggy module gives exactly
`4 failed, 15 passed` — one failure for bug 1 and three for bug 2. The test
runner asserts that count, so if you change either module the count has to be
updated with it.
sample-run.txt
Day 071 lab — captured session
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>; on your machine they are your real repository path.
===============================================================================
1. The reference suite, in full
===============================================================================
$ pytest examples
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: <repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collected 19 items
examples/test_textstats.py ................... [100%]
============================== 19 passed in 0.01s ==============================
exit: 0
===============================================================================
2. The same run, quiet, then verbose
===============================================================================
$ pytest examples -q
................... [100%]
19 passed in 0.01s
exit: 0
$ pytest examples -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <venv>/bin/python3.14
rootdir: <repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 19 items
examples/test_textstats.py::test_words_splits_on_punctuation_and_lowercases PASSED [ 5%]
examples/test_textstats.py::test_words_keeps_an_apostrophe_inside_a_word PASSED [ 10%]
examples/test_textstats.py::test_words_of_empty_text_is_empty PASSED [ 15%]
examples/test_textstats.py::test_words_ignores_digits_and_symbols PASSED [ 21%]
examples/test_textstats.py::test_word_count_of_the_sample_is_twelve PASSED [ 26%]
examples/test_textstats.py::test_word_count_of_empty_text_is_zero PASSED [ 31%]
examples/test_textstats.py::test_average_word_length_of_two_equal_words PASSED [ 36%]
examples/test_textstats.py::test_average_word_length_of_the_sample PASSED [ 42%]
examples/test_textstats.py::test_average_word_length_of_empty_text_is_zero PASSED [ 47%]
examples/test_textstats.py::TestTopWords::test_returns_exactly_n_items PASSED [ 52%]
examples/test_textstats.py::TestTopWords::test_orders_by_frequency_then_alphabetically PASSED [ 57%]
examples/test_textstats.py::TestTopWords::test_asking_for_more_than_exist_returns_everything PASSED [ 63%]
examples/test_textstats.py::TestTopWords::test_asking_for_zero_returns_nothing PASSED [ 68%]
examples/test_textstats.py::TestTopWords::test_empty_text_has_no_top_words PASSED [ 73%]
examples/test_textstats.py::test_reading_time_rounds_up_to_a_whole_minute PASSED [ 78%]
examples/test_textstats.py::test_reading_time_of_five_hundred_words_at_two_hundred_a_minute PASSED [ 84%]
examples/test_textstats.py::test_reading_time_honours_a_slower_speed PASSED [ 89%]
examples/test_textstats.py::test_reading_time_of_empty_text_is_zero PASSED [ 94%]
examples/test_textstats.py::test_reading_time_rejects_a_non_positive_speed PASSED [100%]
============================== 19 passed in 0.02s ==============================
exit: 0
===============================================================================
3. Collection only — every test id, no execution
===============================================================================
$ pytest examples --collect-only -q
test_textstats.py::test_words_splits_on_punctuation_and_lowercases
test_textstats.py::test_words_keeps_an_apostrophe_inside_a_word
test_textstats.py::test_words_of_empty_text_is_empty
test_textstats.py::test_words_ignores_digits_and_symbols
test_textstats.py::test_word_count_of_the_sample_is_twelve
test_textstats.py::test_word_count_of_empty_text_is_zero
test_textstats.py::test_average_word_length_of_two_equal_words
test_textstats.py::test_average_word_length_of_the_sample
test_textstats.py::test_average_word_length_of_empty_text_is_zero
test_textstats.py::TestTopWords::test_returns_exactly_n_items
test_textstats.py::TestTopWords::test_orders_by_frequency_then_alphabetically
test_textstats.py::TestTopWords::test_asking_for_more_than_exist_returns_everything
test_textstats.py::TestTopWords::test_asking_for_zero_returns_nothing
test_textstats.py::TestTopWords::test_empty_text_has_no_top_words
test_textstats.py::test_reading_time_rounds_up_to_a_whole_minute
test_textstats.py::test_reading_time_of_five_hundred_words_at_two_hundred_a_minute
test_textstats.py::test_reading_time_honours_a_slower_speed
test_textstats.py::test_reading_time_of_empty_text_is_zero
test_textstats.py::test_reading_time_rejects_a_non_positive_speed
19 tests collected in 0.01s
exit: 0
===============================================================================
4. Selecting a subset with -k
===============================================================================
$ pytest examples -q -k reading_time
..... [100%]
5 passed, 14 deselected in 0.01s
exit: 0
$ pytest examples -v -k 'top_words or TestTopWords'
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <venv>/bin/python3.14
rootdir: <repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 19 items / 14 deselected / 5 selected
examples/test_textstats.py::TestTopWords::test_returns_exactly_n_items PASSED [ 20%]
examples/test_textstats.py::TestTopWords::test_orders_by_frequency_then_alphabetically PASSED [ 40%]
examples/test_textstats.py::TestTopWords::test_asking_for_more_than_exist_returns_everything PASSED [ 60%]
examples/test_textstats.py::TestTopWords::test_asking_for_zero_returns_nothing PASSED [ 80%]
examples/test_textstats.py::TestTopWords::test_empty_text_has_no_top_words PASSED [100%]
======================= 5 passed, 14 deselected in 0.01s =======================
exit: 0
===============================================================================
5. What a failure looks like — assertion rewriting at work
===============================================================================
$ pytest examples/failure-demo
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: <repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples/failure-demo
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collected 5 items
examples/failure-demo/test_failure_report.py FFFFF [100%]
=================================== FAILURES ===================================
_______________________ test_a_simple_number_comparison ________________________
def test_a_simple_number_comparison():
> assert add(1, 2) == 3
E assert 4 == 3
E + where 4 = add(1, 2)
examples/failure-demo/test_failure_report.py:20: AssertionError
____________________________ test_a_list_comparison ____________________________
def test_a_list_comparison():
expected = ["the", "cat", "sat", "on", "the", "mat"]
actual = ["the", "cat", "sat", "on", "a", "mat"]
> assert actual == expected
E AssertionError: assert ['the', 'cat'...', 'a', 'mat'] == ['the', 'cat'... 'the', 'mat']
E
E At index 4 diff: 'a' != 'the'
E Use -v to get more diff
examples/failure-demo/test_failure_report.py:26: AssertionError
____________________________ test_a_dict_comparison ____________________________
def test_a_dict_comparison():
expected = {"words": 12, "unique": 9, "mean_length": 3.83}
actual = {"words": 12, "unique": 8, "mean_length": 3.83}
> assert actual == expected
E AssertionError: assert {'words': 12,...length': 3.83} == {'words': 12,...length': 3.83}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'unique': 8} != {'unique': 9}
E Use -v to get more diff
examples/failure-demo/test_failure_report.py:32: AssertionError
___________________________ test_a_string_comparison ___________________________
def test_a_string_comparison():
> assert "reading time: 4 minutes" == "reading time: 3 minutes"
E AssertionError: assert 'reading time: 4 minutes' == 'reading time: 3 minutes'
E
E - reading time: 3 minutes
E ? ^
E + reading time: 4 minutes
E ? ^
examples/failure-demo/test_failure_report.py:36: AssertionError
____________________ test_an_exception_that_was_not_raised _____________________
def test_an_exception_that_was_not_raised():
> with pytest.raises(ValueError):
^^^^^^^^^^^^^^^^^^^^^^^^^
E Failed: DID NOT RAISE ValueError
examples/failure-demo/test_failure_report.py:40: Failed
=========================== short test summary info ============================
FAILED examples/failure-demo/test_failure_report.py::test_a_simple_number_comparison
FAILED examples/failure-demo/test_failure_report.py::test_a_list_comparison
FAILED examples/failure-demo/test_failure_report.py::test_a_dict_comparison
FAILED examples/failure-demo/test_failure_report.py::test_a_string_comparison
FAILED examples/failure-demo/test_failure_report.py::test_an_exception_that_was_not_raised
============================== 5 failed in 0.02s ===============================
exit: 1
===============================================================================
6. The same failures under --tb=short, and under -x
===============================================================================
$ pytest examples/failure-demo -q --tb=short
FFFFF [100%]
=================================== FAILURES ===================================
_______________________ test_a_simple_number_comparison ________________________
examples/failure-demo/test_failure_report.py:20: in test_a_simple_number_comparison
assert add(1, 2) == 3
E assert 4 == 3
E + where 4 = add(1, 2)
____________________________ test_a_list_comparison ____________________________
examples/failure-demo/test_failure_report.py:26: in test_a_list_comparison
assert actual == expected
E AssertionError: assert ['the', 'cat'...', 'a', 'mat'] == ['the', 'cat'... 'the', 'mat']
E
E At index 4 diff: 'a' != 'the'
E Use -v to get more diff
____________________________ test_a_dict_comparison ____________________________
examples/failure-demo/test_failure_report.py:32: in test_a_dict_comparison
assert actual == expected
E AssertionError: assert {'words': 12,...length': 3.83} == {'words': 12,...length': 3.83}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'unique': 8} != {'unique': 9}
E Use -v to get more diff
___________________________ test_a_string_comparison ___________________________
examples/failure-demo/test_failure_report.py:36: in test_a_string_comparison
assert "reading time: 4 minutes" == "reading time: 3 minutes"
E AssertionError: assert 'reading time: 4 minutes' == 'reading time: 3 minutes'
E
E - reading time: 3 minutes
E ? ^
E + reading time: 4 minutes
E ? ^
____________________ test_an_exception_that_was_not_raised _____________________
examples/failure-demo/test_failure_report.py:40: in test_an_exception_that_was_not_raised
with pytest.raises(ValueError):
^^^^^^^^^^^^^^^^^^^^^^^^^
E Failed: DID NOT RAISE ValueError
=========================== short test summary info ============================
FAILED examples/failure-demo/test_failure_report.py::test_a_simple_number_comparison
FAILED examples/failure-demo/test_failure_report.py::test_a_list_comparison
FAILED examples/failure-demo/test_failure_report.py::test_a_dict_comparison
FAILED examples/failure-demo/test_failure_report.py::test_a_string_comparison
FAILED examples/failure-demo/test_failure_report.py::test_an_exception_that_was_not_raised
5 failed in 0.01s
exit: 1
$ pytest examples/failure-demo -x -q --tb=line
F
=================================== FAILURES ===================================
E assert 4 == 3
+ where 4 = add(1, 2)
<repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples/failure-demo/test_failure_report.py:20: assert 4 == 3
=========================== short test summary info ============================
FAILED examples/failure-demo/test_failure_report.py::test_a_simple_number_comparison
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed in 0.00s
exit: 1
===============================================================================
7. The same three failures under unittest, for comparison
===============================================================================
$ python3 examples/failure-demo/unittest_failure.py
FFF
======================================================================
FAIL: test_a_bare_assert_inside_unittest (__main__.ComparisonTests.test_a_bare_assert_inside_unittest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples/failure-demo/unittest_failure.py", line 30, in test_a_bare_assert_inside_unittest
assert add(1, 2) == 3
^^^^^^^^^^^^^^
AssertionError
======================================================================
FAIL: test_a_list_comparison (__main__.ComparisonTests.test_a_list_comparison)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples/failure-demo/unittest_failure.py", line 25, in test_a_list_comparison
self.assertEqual(actual, expected)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
AssertionError: Lists differ: ['the', 'cat', 'sat', 'on', 'a', 'mat'] != ['the', 'cat', 'sat', 'on', 'the', 'mat']
First differing element 4:
'a'
'the'
- ['the', 'cat', 'sat', 'on', 'a', 'mat']
? ^
+ ['the', 'cat', 'sat', 'on', 'the', 'mat']
? ^^^
======================================================================
FAIL: test_a_simple_number_comparison (__main__.ComparisonTests.test_a_simple_number_comparison)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples/failure-demo/unittest_failure.py", line 20, in test_a_simple_number_comparison
self.assertEqual(add(1, 2), 3)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
AssertionError: 4 != 3
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=3)
exit: 1
===============================================================================
8. The starter, before you have written anything
===============================================================================
$ pytest starter -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <venv>/bin/python3.14
rootdir: <repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/starter
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 10 items
starter/test_textstats.py::test_words_splits_on_punctuation_and_lowercases PASSED [ 10%]
starter/test_textstats.py::test_words_of_empty_text_is_empty SKIPPED [ 20%]
starter/test_textstats.py::test_word_count_of_the_sample SKIPPED (ex...) [ 30%]
starter/test_textstats.py::test_average_word_length_of_empty_text_is_zero SKIPPED [ 40%]
starter/test_textstats.py::test_average_word_length_of_known_text SKIPPED [ 50%]
starter/test_textstats.py::TestTopWords::test_returns_exactly_n_items SKIPPED [ 60%]
starter/test_textstats.py::TestTopWords::test_asking_for_more_than_exist_returns_everything SKIPPED [ 70%]
starter/test_textstats.py::TestTopWords::test_asking_for_zero_returns_nothing SKIPPED [ 80%]
starter/test_textstats.py::TestTopWords::test_empty_text_has_no_top_words SKIPPED [ 90%]
starter/test_textstats.py::test_reading_time_rules SKIPPED (exercise...) [100%]
========================= 1 passed, 9 skipped in 0.01s =========================
exit: 0
===============================================================================
9. Exit codes
===============================================================================
$ pytest examples -q ; echo $?
pytest exit code on success: 0
exit: 0
$ pytest examples/failure-demo -q ; echo $?
pytest exit code with failures: 1
exit: 0
$ cd $(mktemp -d) && pytest . -q
no tests ran in 0.00s
exit: 5
===============================================================================
10. The suite proves it tests something: break one line, watch it fail
===============================================================================
$ sed -i '' 's/return len(words(text))/return len(words(text)) + 1/' textstats.py
$ pytest . -q --tb=line # in a temp copy with word_count broken
....FF..........FF. [100%]
=================================== FAILURES ===================================
E AssertionError: assert 13 == 12
+ where 13 = word_count('The quick brown fox jumps over the lazy dog. The dog barks.')
<tmp>/test_textstats.py:54: AssertionError: assert 13 == 12
E AssertionError: assert 1 == 0
+ where 1 = word_count('')
<tmp>/test_textstats.py:58: AssertionError: assert 1 == 0
E AssertionError: assert 6 == 5
+ where 6 = reading_time_minutes(('word ' * 500), 100)
<tmp>/test_textstats.py:120: AssertionError: assert 6 == 5
E AssertionError: assert 1 == 0
+ where 1 = reading_time_minutes('')
<tmp>/test_textstats.py:124: AssertionError: assert 1 == 0
=========================== short test summary info ============================
FAILED test_textstats.py::test_word_count_of_the_sample_is_twelve - Assertion...
FAILED test_textstats.py::test_word_count_of_empty_text_is_zero - AssertionEr...
FAILED test_textstats.py::test_reading_time_honours_a_slower_speed - Assertio...
FAILED test_textstats.py::test_reading_time_of_empty_text_is_zero - Assertion...
4 failed, 15 passed in 0.01s
exit: 1
===============================================================================
11. The vacuous-test demonstration
===============================================================================
$ bash examples/vacuous-demo/prove_it.sh
Breaking top_words: ranked[:n] becomes ranked[: n - 1]
--- the four vacuous tests, on the BROKEN module ---
.... [100%]
4 passed, 1 deselected in 0.00s
exit: 0
--- the one honest test, on the same BROKEN module ---
F [100%]
=================================== FAILURES ===================================
_________ test_top_words_returns_exactly_two_items_in_the_right_order __________
test_vacuous.py:52: in test_top_words_returns_exactly_two_items_in_the_right_order
assert top_words(TEXT, 2) == [("a", 3), ("b", 2)]
E AssertionError: assert [('a', 3)] == [('a', 3), ('b', 2)]
E
E Right contains one more item: ('b', 2)
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_vacuous.py::test_top_words_returns_exactly_two_items_in_the_right_order
1 failed, 4 deselected in 0.01s
exit: 1
Point made: four green tests, one broken function, zero warnings.
exit: 0
===============================================================================
12. Testing without pytest: unittest and doctest, both standard library
===============================================================================
$ python3 examples/unittest_demo.py -v
test_average_word_length_of_empty_text (__main__.TextStatsTests.test_average_word_length_of_empty_text) ... ok
test_average_word_length_of_the_sample (__main__.TextStatsTests.test_average_word_length_of_the_sample) ... ok
test_reading_time_rejects_a_non_positive_speed (__main__.TextStatsTests.test_reading_time_rejects_a_non_positive_speed) ... ok
test_top_words_returns_exactly_n_items (__main__.TextStatsTests.test_top_words_returns_exactly_n_items) ... ok
test_word_count_of_the_sample (__main__.TextStatsTests.test_word_count_of_the_sample) ... ok
test_words_splits_on_punctuation_and_lowercases (__main__.TextStatsTests.test_words_splits_on_punctuation_and_lowercases) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.000s
OK
exit: 0
$ python3 examples/doctest_demo.py
doctest: 8 examples attempted, 0 failed
exit: 0
$ python3 -m doctest -v examples/doctest_demo.py | tail -12
Traceback (most recent call last):
...
ValueError: limit must be at least 1, got 0
ok
1 item had no tests:
doctest_demo
2 items passed all tests:
4 tests in doctest_demo.initials
4 tests in doctest_demo.truncate
8 tests in 3 items.
8 passed.
Test passed.
exit: 0
$ python3 examples/plain_asserts.py
all plain assertions held
exit: 0
===============================================================================
13. The collision you get from running two directories at once
===============================================================================
$ pytest starter examples -q
==================================== ERRORS ====================================
______________________ ERROR collecting test_textstats.py ______________________
import file mismatch:
imported module 'test_textstats' has this __file__ attribute:
<repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/starter/test_textstats.py
which is not the same as the test file we want to collect:
<repo>/labs/sections/programming-with-python/day-071-why-test-and-pytest-basics/examples/test_textstats.py
HINT: remove __pycache__ / .pyc files and/or use a unique basename for your test file modules
=========================== short test summary info ============================
ERROR starter/test_textstats.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.04s
exit: 2
test-run.txt
Day 071 — Your First Real Test Suite
1. The tool itself
ok: pytest --version reports a pytest ( pytest 9.1.1 )
2. The reference suite passes
ok: pytest examples exits 0
ok: pytest examples reports 19 passed
3. Collection: rootdir, test ids, and selection
ok: collection finds test_textstats.py::test_words_of_empty_text_is_empty
ok: collection finds test_textstats.py::test_average_word_length_of_empty_text_is_zero
ok: collection finds test_textstats.py::TestTopWords::test_returns_exactly_n_items
ok: collection finds test_textstats.py::test_reading_time_rejects_a_non_positive_speed
ok: collection finds exactly 19 tests
ok: the TestTopWords class contributes 5 collected tests
ok: -k 'top_words or TestTopWords' selects 5 of 19
ok: -k reading_time selects the 5 reading-time tests
4. Failure reporting and assertion rewriting
ok: the deliberate-failure demo exits 1
ok: assertion rewriting shows both sides: 'assert 4 == 3'
ok: the report explains where the 4 came from
ok: a list mismatch names the index that differs
ok: pytest.raises reports a missing exception
ok: the short test summary lists every failure
ok: -x stops after the first failure
5. Exit codes — the contract continuous integration reads
ok: a run that collects no tests exits 5, not 0
6. The suite actually tests something (the checks that matter)
ok: control: the fixed module passes the reference suite (exit 0)
ok: the sed edit really changed word_count
ok: a one-line break makes the suite FAIL (exit 1, not 0)
ok: the failing run names test_word_count_of_the_sample_is_twelve
ok: the reference suite rejects the buggy starter module
ok: bug 1 caught: average_word_length divides by zero on empty text
ok: bug 2 caught: top_words is off by one
ok: exactly 4 of the 19 reference tests fail on the buggy module
ok: starter/textstats.py still carries the off-by-one slice
ok: examples/textstats.py repairs the slice to ranked[:n]
ok: examples/textstats.py guards the empty-text division
ok: starter/textstats.py still lacks the empty-text guard
ok: four vacuous tests stay green on broken code; the honest one fails
ok: all 5 vacuous-demo tests pass on the CORRECT module
ok: norecursedirs keeps the wrong-on-purpose dirs out of 'pytest examples'
7. The starter is runnable before you start
ok: pytest starter exits 0 with the exercises unfinished
ok: the starter has 1 worked test and 9 skipped exercises
ok: the starter collects 10 tests
8. Testing without pytest: assert, unittest, doctest
ok: examples/plain_asserts.py exits 0 — bare asserts are already a suite
ok: a broken module makes the bare-assert script exit non-zero
ok: examples/unittest_demo.py passes under the stdlib runner
ok: the unittest failure demo exits non-zero
ok: unittest's assertEqual reports both sides
ok: a bare assert under unittest reports no values at all
ok: examples/doctest_demo.py exits 0
ok: doctest runs all 8 documented examples
ok: python3 -m doctest agrees, and says nothing when all is well
9. Nothing here touches the network or the clock
ok: no network, clock or randomness in examples/ or starter/
47 checks, 0 failure(s).
Source files
examples/conftest.py (1454 bytes)
"""Shared setup that pytest finds by itself.
You never import this file and nothing in the test suite mentions it by name.
pytest looks for files called `conftest.py` in the directory it is collecting
from and in every parent directory up to the rootdir, imports them before
collection starts, and makes whatever they define available to every test file
underneath them. That is the whole mechanism.
Two things live here:
* `SAMPLE`, an ordinary module-level constant the tests import by name;
* `sample_text`, a *fixture* — a function decorated with `@pytest.fixture`
whose return value is handed to any test that names `sample_text` as a
parameter. Fixtures are Day 72's subject. Today you only need to recognise
one when you see it: a test parameter that is not a value but a request.
"""
import pytest
# Twelve words. Counted by hand, deliberately, because a test whose expected
# value came out of the code it is testing proves nothing at all.
#
# the quick brown fox jumps over the lazy dog the dog barks
# 1 2 3 4 5 6 7 8 9 10 11 12
#
# Frequencies: the=3, dog=2, and one each of barks, brown, fox, jumps, lazy,
# over, quick. Total characters in words: 46, so the mean is 46/12 = 3.8333...
SAMPLE = "The quick brown fox jumps over the lazy dog. The dog barks."
@pytest.fixture
def sample_text() -> str:
"""The sample sentence, handed fresh to every test that asks for it."""
return SAMPLE
examples/doctest_demo.py (1885 bytes)
"""Tests that live inside the documentation — `doctest`, also standard library.
Run it:
python3 -m doctest -v examples/doctest_demo.py
`doctest` scans docstrings for lines beginning with the interactive prompt
`>>>`, runs them, and compares what comes back with the text on the following
line — character for character. The examples are documentation a reader trusts
*because* a machine checks them.
That character-for-character comparison is the whole story of doctest: it is
unbeatable for short, exact, illustrative results, and it is the wrong tool
the moment the result is long, unordered, or has a memory address in it.
"""
def initials(full_name: str) -> str:
"""Return the initials of a name, uppercased and dot-separated.
>>> initials("ada lovelace")
'A.L.'
>>> initials("Grace Brewster Murray Hopper")
'G.B.M.H.'
>>> initials(" plato ")
'P.'
>>> initials("")
''
"""
parts = full_name.split()
return "".join(part[0].upper() + "." for part in parts)
def truncate(text: str, limit: int) -> str:
"""Shorten text to at most `limit` characters, ending with a single dot.
>>> truncate("hello", 10)
'hello'
>>> truncate("hello there friend", 8)
'hello t.'
>>> truncate("abc", 1)
'.'
Asking for a limit below one is a programming error, not a short string:
>>> truncate("abc", 0)
Traceback (most recent call last):
...
ValueError: limit must be at least 1, got 0
"""
if limit < 1:
raise ValueError(f"limit must be at least 1, got {limit}")
if len(text) <= limit:
return text
return text[: limit - 1] + "."
if __name__ == "__main__":
import doctest
failures, attempted = doctest.testmod(verbose=False)
print(f"doctest: {attempted} examples attempted, {failures} failed")
raise SystemExit(1 if failures else 0)
examples/failure-demo/pytest.ini (276 bytes)
# This directory is a rootdir of its own, so `pytest examples/failure-demo`
# reports short paths and `pytest examples` skips it (see the `norecursedirs`
# line in examples/pytest.ini). Everything in here fails ON PURPOSE.
[pytest]
testpaths = .
addopts = -p no:cacheprovider
examples/failure-demo/test_failure_report.py (1101 bytes)
"""Five tests that fail on purpose, so you can read a failure report.
pytest examples/failure-demo
Every failure here is deliberate. The point is the shape of the report: what
pytest prints when a bare `assert` is false, and how much of the comparison it
recovers for you. Compare the same five failures under `--tb=short`, `-q`,
and `-x`.
"""
import pytest
def add(a, b):
"""Deliberately wrong by one, so the arithmetic assertion fails."""
return a + b + 1
def test_a_simple_number_comparison():
assert add(1, 2) == 3
def test_a_list_comparison():
expected = ["the", "cat", "sat", "on", "the", "mat"]
actual = ["the", "cat", "sat", "on", "a", "mat"]
assert actual == expected
def test_a_dict_comparison():
expected = {"words": 12, "unique": 9, "mean_length": 3.83}
actual = {"words": 12, "unique": 8, "mean_length": 3.83}
assert actual == expected
def test_a_string_comparison():
assert "reading time: 4 minutes" == "reading time: 3 minutes"
def test_an_exception_that_was_not_raised():
with pytest.raises(ValueError):
int("42")
examples/failure-demo/unittest_failure.py (1023 bytes)
"""The same three failures under `unittest`, for comparison.
python3 examples/failure-demo/unittest_failure.py
Run this next to `pytest examples/failure-demo` and put the two reports side
by side. Both tell you the truth. Ask yourself how many seconds each one takes
to read.
"""
import unittest
def add(a, b):
"""Deliberately wrong by one, so the arithmetic assertion fails."""
return a + b + 1
class ComparisonTests(unittest.TestCase):
def test_a_simple_number_comparison(self):
self.assertEqual(add(1, 2), 3)
def test_a_list_comparison(self):
expected = ["the", "cat", "sat", "on", "the", "mat"]
actual = ["the", "cat", "sat", "on", "a", "mat"]
self.assertEqual(actual, expected)
def test_a_bare_assert_inside_unittest(self):
# unittest runs a plain assert perfectly well — it just cannot tell
# you anything about it, because nothing rewrote the statement.
assert add(1, 2) == 3
if __name__ == "__main__":
unittest.main()
examples/plain_asserts.py (1404 bytes)
"""A test suite with no test runner at all — just `assert`.
This is the whole idea of testing, stripped to the bone. Run it:
python3 examples/plain_asserts.py
It prints nothing and exits 0 when everything holds. Break `textstats.py` and
it exits 1 with an AssertionError. That is already a test suite: arrange, act,
assert, and a process exit code a machine can read.
Then compare what it tells you on failure with what pytest tells you. That
difference — not the concept — is what you are paying pytest for.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from textstats import average_word_length, top_words, word_count, words # noqa: E402
# --- words -----------------------------------------------------------------
assert words("The cat. The hat!") == ["the", "cat", "the", "hat"]
assert words("") == []
# --- word_count ------------------------------------------------------------
assert word_count("The cat. The hat!") == 4
assert word_count("") == 0
# --- average_word_length ---------------------------------------------------
assert average_word_length("the cat") == 3.0
assert average_word_length("") == 0.0
# --- top_words -------------------------------------------------------------
assert top_words("a b a c a b", 2) == [("a", 3), ("b", 2)]
assert top_words("a b a c a b", 0) == []
print("all plain assertions held")
examples/pytest.ini (834 bytes)
# The presence of this file is what makes `examples/` the rootdir when you run
# `pytest examples`. pytest walks up from the paths you gave it looking for a
# configuration file (pytest.ini, pyproject.toml, tox.ini, setup.cfg); the
# directory holding the first one it finds becomes the rootdir, and the rootdir
# is what every path in the report is printed relative to.
#
# Without this file pytest would keep walking upward and pick some ancestor
# directory you did not choose, and the report would be full of long paths.
[pytest]
testpaths = .
addopts = -p no:cacheprovider
# `failure-demo/` and `vacuous-demo/` are deliberately wrong, so a plain
# `pytest examples` must not walk into them. Run each one deliberately:
# pytest examples/failure-demo · pytest examples/vacuous-demo
norecursedirs = failure-demo vacuous-demo
examples/test_textstats.py (4158 bytes)
"""The worked reference suite for `textstats`.
Every test below is arrange, act, assert — usually in exactly three lines, and
often in one when the arrangement is a literal. Read them as sentences: the
name says what should be true, the body proves it.
Run it:
pytest examples
Nothing here imports pytest except the two tests that need `pytest.raises` and
`pytest.approx`, and nothing here inherits from anything. A test is a plain
function whose name starts with `test_`.
"""
import pytest
from conftest import SAMPLE
from textstats import (
average_word_length,
reading_time_minutes,
top_words,
word_count,
words,
)
# --------------------------------------------------------------------------
# words() — the tokeniser everything else is built on
# --------------------------------------------------------------------------
def test_words_splits_on_punctuation_and_lowercases():
assert words("The cat. The hat!") == ["the", "cat", "the", "hat"]
def test_words_keeps_an_apostrophe_inside_a_word():
assert words("don't stop") == ["don't", "stop"]
def test_words_of_empty_text_is_empty():
assert words("") == []
def test_words_ignores_digits_and_symbols():
assert words("3 apples & 4 pears") == ["apples", "pears"]
# --------------------------------------------------------------------------
# word_count()
# --------------------------------------------------------------------------
def test_word_count_of_the_sample_is_twelve(sample_text):
assert word_count(sample_text) == 12
def test_word_count_of_empty_text_is_zero():
assert word_count("") == 0
# --------------------------------------------------------------------------
# average_word_length()
# --------------------------------------------------------------------------
def test_average_word_length_of_two_equal_words():
# "the" and "cat" are three characters each, so the mean is exactly 3.0.
assert average_word_length("the cat") == 3.0
def test_average_word_length_of_the_sample(sample_text):
# 46 characters over 12 words is 3.8333..., rounded to 3.83.
assert average_word_length(sample_text) == pytest.approx(3.83)
def test_average_word_length_of_empty_text_is_zero():
# The bug this catches: the original divided by len(found) with no words.
assert average_word_length("") == 0.0
# --------------------------------------------------------------------------
# top_words() — grouped in a class purely to show that pytest collects
# `Test*` classes too. No base class, no setUp, no self-management.
# --------------------------------------------------------------------------
class TestTopWords:
def test_returns_exactly_n_items(self):
# The bug this catches: the original sliced to n - 1.
assert top_words("a b a c a b", 2) == [("a", 3), ("b", 2)]
def test_orders_by_frequency_then_alphabetically(self, sample_text):
assert top_words(sample_text, 3) == [("the", 3), ("dog", 2), ("barks", 1)]
def test_asking_for_more_than_exist_returns_everything(self):
assert top_words("a b a c a b", 9) == [("a", 3), ("b", 2), ("c", 1)]
def test_asking_for_zero_returns_nothing(self):
assert top_words("a b a c a b", 0) == []
def test_empty_text_has_no_top_words(self):
assert top_words("", 5) == []
# --------------------------------------------------------------------------
# reading_time_minutes()
# --------------------------------------------------------------------------
def test_reading_time_rounds_up_to_a_whole_minute():
assert reading_time_minutes("hello there") == 1
def test_reading_time_of_five_hundred_words_at_two_hundred_a_minute():
# 500 / 200 is 2.5, and reading time always rounds up.
assert reading_time_minutes("word " * 500) == 3
def test_reading_time_honours_a_slower_speed():
assert reading_time_minutes("word " * 500, 100) == 5
def test_reading_time_of_empty_text_is_zero():
assert reading_time_minutes("") == 0
def test_reading_time_rejects_a_non_positive_speed():
with pytest.raises(ValueError, match="must be positive"):
reading_time_minutes(SAMPLE, 0)
examples/textstats.py (3310 bytes)
"""Small text statistics helpers — the FIXED reference module.
This is `starter/textstats.py` with its two bugs repaired. Two functions
differ, and both repairs are marked below with the comment `# repaired`.
Compare the two files once you have found the bugs yourself:
diff starter/textstats.py examples/textstats.py
The docstrings are unchanged, because the docstrings were never wrong — the
code was. That is the usual shape of a bug: the specification was fine and
somebody's fingers were not.
"""
from __future__ import annotations
import math
import re
# A "word" is a run of letters, optionally containing apostrophes, so that
# "don't" is one word and "state-of-the-art" is four.
WORD_PATTERN = re.compile(r"[A-Za-z']+")
def words(text: str) -> list[str]:
"""Split text into lowercase words.
Punctuation and digits are separators. The result is in reading order and
may contain repeats.
words("The cat. The hat!") -> ['the', 'cat', 'the', 'hat']
words("") -> []
"""
return [match.group(0).lower() for match in WORD_PATTERN.finditer(text)]
def word_count(text: str) -> int:
"""Count the words in text.
word_count("The cat. The hat!") -> 4
word_count("") -> 0
"""
return len(words(text))
def average_word_length(text: str) -> float:
"""Mean number of characters per word, rounded to two decimal places.
Empty text has no words, so the average is defined to be 0.0 — asking for
the mean of nothing is not an error, it is just nothing.
average_word_length("the cat") -> 3.0
average_word_length("") -> 0.0
"""
found = words(text)
if not found: # repaired: the mean of no words is 0.0, not a crash
return 0.0
total_characters = sum(len(word) for word in found)
return round(total_characters / len(found), 2)
def top_words(text: str, n: int) -> list[tuple[str, int]]:
"""The n most frequent words, most frequent first.
Ties are broken alphabetically so the result is deterministic. Asking for
more words than exist returns everything. Asking for zero returns nothing.
top_words("a b a c a b", 2) -> [('a', 3), ('b', 2)]
top_words("a b a c a b", 9) -> [('a', 3), ('b', 2), ('c', 1)]
top_words("a b a c a b", 0) -> []
"""
counts: dict[str, int] = {}
for word in words(text):
counts[word] = counts.get(word, 0) + 1
ranked = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
return ranked[:n] # repaired: a slice already stops before index n
def reading_time_minutes(text: str, words_per_minute: int = 200) -> int:
"""Whole minutes needed to read text, always rounded up.
Any text at all takes at least one minute; empty text takes none. The
reading speed must be a positive number of words per minute.
reading_time_minutes("hello there") -> 1
reading_time_minutes("word " * 500) -> 3
reading_time_minutes("word " * 500, 100) -> 5
reading_time_minutes("") -> 0
"""
if words_per_minute <= 0:
raise ValueError(f"words_per_minute must be positive, got {words_per_minute}")
return math.ceil(word_count(text) / words_per_minute)
examples/unittest_demo.py (1799 bytes)
"""The same tests written with `unittest`, which ships with Python.
Run it:
python3 examples/unittest_demo.py -v
Nothing to install. Notice the three costs pytest removes: you inherit from
`unittest.TestCase`, you call a differently-named method for every kind of
comparison (`assertEqual`, `assertAlmostEqual`, `assertRaises`, `assertIn`,
`assertTrue`), and the boilerplate at the bottom exists so the file can be run
directly.
Notice also what `unittest` gives you for free that is genuinely good: it is
in the standard library, so a suite written this way runs on any machine with
Python and no network at all.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from textstats import ( # noqa: E402
average_word_length,
reading_time_minutes,
top_words,
word_count,
words,
)
SAMPLE = "The quick brown fox jumps over the lazy dog. The dog barks."
class TextStatsTests(unittest.TestCase):
def test_words_splits_on_punctuation_and_lowercases(self):
self.assertEqual(words("The cat. The hat!"), ["the", "cat", "the", "hat"])
def test_word_count_of_the_sample(self):
self.assertEqual(word_count(SAMPLE), 12)
def test_average_word_length_of_empty_text(self):
self.assertEqual(average_word_length(""), 0.0)
def test_average_word_length_of_the_sample(self):
self.assertAlmostEqual(average_word_length(SAMPLE), 3.83, places=2)
def test_top_words_returns_exactly_n_items(self):
self.assertEqual(top_words("a b a c a b", 2), [("a", 3), ("b", 2)])
def test_reading_time_rejects_a_non_positive_speed(self):
with self.assertRaises(ValueError):
reading_time_minutes(SAMPLE, 0)
if __name__ == "__main__":
unittest.main()
examples/vacuous-demo/prove_it.sh (1920 bytes)
#!/usr/bin/env bash
# Prove that four of the five tests next door are worthless.
#
# bash examples/vacuous-demo/prove_it.sh
#
# It copies this directory to a temporary one, breaks `top_words` with a
# single sed, and runs the suite twice: once with only the four vacuous tests
# selected, once with the honest one. The first stays green on broken code.
# The second does not. Nothing in your working tree is modified.
set -u
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
lab_dir="$(cd "${here}/../.." && pwd)"
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. See requirements/README.md." >&2
exit 1
}
work="$(mktemp -d "${TMPDIR:-/tmp}/vacuous.XXXXXX")"
cp "${here}/textstats.py" "${here}/test_vacuous.py" "${here}/pytest.ini" "${work}/"
echo "Breaking top_words: ranked[:n] becomes ranked[: n - 1]"
sed -i.bak 's/return ranked\[:n\].*/return ranked[: n - 1]/' "${work}/textstats.py"
rm -f "${work}/textstats.py.bak"
echo
echo "--- the four vacuous tests, on the BROKEN module ---"
(cd "${work}" && "${pytest_bin}" . -q -k "not exactly_two_items")
vacuous_exit=$?
echo "exit: ${vacuous_exit}"
echo
echo "--- the one honest test, on the same BROKEN module ---"
(cd "${work}" && "${pytest_bin}" . -q -k "exactly_two_items" --tb=short)
honest_exit=$?
echo "exit: ${honest_exit}"
echo
rm -rf "${work}"
if [ "${vacuous_exit}" -eq 0 ] && [ "${honest_exit}" -ne 0 ]; then
echo "Point made: four green tests, one broken function, zero warnings."
exit 0
fi
echo "Unexpected: the demonstration did not behave as described." >&2
exit 1
examples/vacuous-demo/pytest.ini (200 bytes)
# A rootdir of its own, so `pytest examples/vacuous-demo` reports short paths
# and `pytest examples` skips it. This directory exists to be wrong.
[pytest]
testpaths = .
addopts = -p no:cacheprovider
examples/vacuous-demo/test_vacuous.py (1513 bytes)
"""Four tests that pass no matter what the code does.
pytest examples/vacuous-demo -v
All four are green. All four are worthless. Prove it with the script next to
this file, which breaks `top_words` and re-runs them:
bash examples/vacuous-demo/prove_it.sh
The suite stays green. That is the failure mode this whole lab exists to make
visible: a green suite is a claim, and an untested claim is a lie you tell
yourself every morning at nine.
The fifth test at the bottom is the same intent written properly. It is the
only one that notices.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from textstats import top_words, word_count # noqa: E402
TEXT = "a b a c a b"
def test_top_words_returns_something():
# A list is a list. This would pass if the function returned garbage.
assert top_words(TEXT, 2) is not None
def test_top_words_returns_a_list():
# Still true of the wrong list.
assert isinstance(top_words(TEXT, 2), list)
def test_top_words_does_not_crash():
# "It ran" is not a specification.
top_words(TEXT, 2)
assert True
def test_word_count_is_not_negative():
# A count is never negative whatever the bug, so this can never fail.
assert word_count(TEXT) >= 0
def test_top_words_returns_exactly_two_items_in_the_right_order():
# The same intent, written so it can fail. This is the only test here
# that would tell you the truth.
assert top_words(TEXT, 2) == [("a", 3), ("b", 2)]
examples/vacuous-demo/textstats.py (3310 bytes)
"""Small text statistics helpers — the FIXED reference module.
This is `starter/textstats.py` with its two bugs repaired. Two functions
differ, and both repairs are marked below with the comment `# repaired`.
Compare the two files once you have found the bugs yourself:
diff starter/textstats.py examples/textstats.py
The docstrings are unchanged, because the docstrings were never wrong — the
code was. That is the usual shape of a bug: the specification was fine and
somebody's fingers were not.
"""
from __future__ import annotations
import math
import re
# A "word" is a run of letters, optionally containing apostrophes, so that
# "don't" is one word and "state-of-the-art" is four.
WORD_PATTERN = re.compile(r"[A-Za-z']+")
def words(text: str) -> list[str]:
"""Split text into lowercase words.
Punctuation and digits are separators. The result is in reading order and
may contain repeats.
words("The cat. The hat!") -> ['the', 'cat', 'the', 'hat']
words("") -> []
"""
return [match.group(0).lower() for match in WORD_PATTERN.finditer(text)]
def word_count(text: str) -> int:
"""Count the words in text.
word_count("The cat. The hat!") -> 4
word_count("") -> 0
"""
return len(words(text))
def average_word_length(text: str) -> float:
"""Mean number of characters per word, rounded to two decimal places.
Empty text has no words, so the average is defined to be 0.0 — asking for
the mean of nothing is not an error, it is just nothing.
average_word_length("the cat") -> 3.0
average_word_length("") -> 0.0
"""
found = words(text)
if not found: # repaired: the mean of no words is 0.0, not a crash
return 0.0
total_characters = sum(len(word) for word in found)
return round(total_characters / len(found), 2)
def top_words(text: str, n: int) -> list[tuple[str, int]]:
"""The n most frequent words, most frequent first.
Ties are broken alphabetically so the result is deterministic. Asking for
more words than exist returns everything. Asking for zero returns nothing.
top_words("a b a c a b", 2) -> [('a', 3), ('b', 2)]
top_words("a b a c a b", 9) -> [('a', 3), ('b', 2), ('c', 1)]
top_words("a b a c a b", 0) -> []
"""
counts: dict[str, int] = {}
for word in words(text):
counts[word] = counts.get(word, 0) + 1
ranked = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
return ranked[:n] # repaired: a slice already stops before index n
def reading_time_minutes(text: str, words_per_minute: int = 200) -> int:
"""Whole minutes needed to read text, always rounded up.
Any text at all takes at least one minute; empty text takes none. The
reading speed must be a positive number of words per minute.
reading_time_minutes("hello there") -> 1
reading_time_minutes("word " * 500) -> 3
reading_time_minutes("word " * 500, 100) -> 5
reading_time_minutes("") -> 0
"""
if words_per_minute <= 0:
raise ValueError(f"words_per_minute must be positive, got {words_per_minute}")
return math.ceil(word_count(text) / words_per_minute)
metadata.yml (1240 bytes)
lesson_id: D071
day: 71
kind: python-program
languages: [python, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-071-why-test-and-pytest-basics
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/pytest --version
run_commands:
- .venv/bin/pytest examples
- .venv/bin/pytest examples -q
- .venv/bin/pytest examples --collect-only -q
- .venv/bin/pytest examples -k reading_time
- .venv/bin/pytest examples/failure-demo
- .venv/bin/pytest examples/failure-demo -x --tb=short
- bash examples/vacuous-demo/prove_it.sh
- python3 examples/plain_asserts.py
- python3 examples/unittest_demo.py -v
- python3 examples/doctest_demo.py
- .venv/bin/pytest starter -v
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'find . -type d -name __pycache__ -prune -exec rm -rf -- {} +'
- 'rm -rf .venv # optional: removes the installed pytest'
- '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 -> 47 checks, 0 failure(s), exit 0'
requirements/README.md (3842 bytes)
# Dependencies — Day 071 lab
This is the first lab in the course with a `requirements.txt`. Everything from
Day 43 to Day 70 ran on the standard library alone. Week 11 is *about* tools,
so this week the tools are the point.
## What is pinned
```text
pytest==9.1.1
```
One package, one exact version.
| Package | Version | Licence | What it is | Why this lab needs it |
| --- | --- | --- | --- | --- |
| pytest | 9.1.1 | MIT | A test runner: it finds your tests, runs them, and reports what failed | The whole lab is about reading its output and trusting its exit code |
The licence is not taken on trust. After installing, ask the package itself:
```bash
.venv/bin/pip show pytest
```
On the authoring machine that reports `Version: 9.1.1`,
`License-Expression: MIT`, and
`Requires: iniconfig, packaging, pluggy, pygments`.
The version is exact — `==`, not `>=`. A pinned version is why the captured
output in `expected-output/` matches what you see. Test-tool output changes
between releases, and a lab that says "you will see 19 passed" has to name the
version that says it.
## Why nothing else
Installing pytest also pulls in four small libraries it depends on —
`iniconfig` (reading `pytest.ini`), `packaging` (version comparisons),
`pluggy` (the plugin system) and `pygments` (syntax colouring in reports).
That is the whole tree; there is no framework underneath. Everything else this
lab uses — `unittest`, `doctest`, `re`, `math` — ships with Python.
The two standard-library runners are demonstrated deliberately: if you ever
land somewhere you cannot install a package, you can still write and run tests
that day.
## Free and open source
pytest is free and open source under the MIT licence, with no paid tier, no
account, and no telemetry. Installing it downloads from the Python Package
Index. That download is the only moment in this lab that touches the network;
once installed, every command runs fully offline.
## The one-time install
You met `python3 -m venv` on Day 43. Same idea, one directory per lab, so this
lab's pytest cannot collide with anything else on your machine:
```bash
cd labs/sections/programming-with-python/day-071-why-test-and-pytest-basics
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
```
The last command should print `pytest 9.1.1`.
`.venv/` is ignored by version control repository-wide. Never commit it: it is
a build artifact, it is large, and it is specific to your machine and Python
version.
## Running without a lab-local virtual environment
If you already have pytest 9.x somewhere — a system install, a shared
environment, an activated venv — you do not need a second copy. The test
runner resolves the tool in three steps: an explicit override, then this lab's
`.venv/bin/pytest`, then whatever `pytest` is on `PATH`. So both of these work:
```bash
bash tests/run_tests.sh # uses .venv or PATH
PYTEST=/path/to/pytest bash tests/run_tests.sh # uses exactly what you name
```
If none of the three finds a pytest, the runner stops with the install
instructions rather than quietly reporting success on nothing — a test suite
that skips itself and exits 0 is the exact failure this lab teaches you to
distrust.
## Checking your Python
```bash
python3 --version
```
pytest 9.1.1 needs Python 3.9 or newer; this lab was authored and executed on
Python 3.14.0. Windows users: run everything inside WSL, or substitute
`.venv\Scripts\pytest` for `.venv/bin/pytest` and use `python` for `python3`.
## What Week 11 adds later
Day 075 adds `mypy` to this file and Day 076 adds `ruff`, so by Day 077 the
pinned set is three lines. All three are free and open source. Nothing else
gets added: a testing setup that needs a page of dependencies is a testing
setup nobody will run.
requirements/requirements.txt (14 bytes)
pytest==9.1.1
starter/conftest.py (1454 bytes)
"""Shared setup that pytest finds by itself.
You never import this file and nothing in the test suite mentions it by name.
pytest looks for files called `conftest.py` in the directory it is collecting
from and in every parent directory up to the rootdir, imports them before
collection starts, and makes whatever they define available to every test file
underneath them. That is the whole mechanism.
Two things live here:
* `SAMPLE`, an ordinary module-level constant the tests import by name;
* `sample_text`, a *fixture* — a function decorated with `@pytest.fixture`
whose return value is handed to any test that names `sample_text` as a
parameter. Fixtures are Day 72's subject. Today you only need to recognise
one when you see it: a test parameter that is not a value but a request.
"""
import pytest
# Twelve words. Counted by hand, deliberately, because a test whose expected
# value came out of the code it is testing proves nothing at all.
#
# the quick brown fox jumps over the lazy dog the dog barks
# 1 2 3 4 5 6 7 8 9 10 11 12
#
# Frequencies: the=3, dog=2, and one each of barks, brown, fox, jumps, lazy,
# over, quick. Total characters in words: 46, so the mean is 46/12 = 3.8333...
SAMPLE = "The quick brown fox jumps over the lazy dog. The dog barks."
@pytest.fixture
def sample_text() -> str:
"""The sample sentence, handed fresh to every test that asks for it."""
return SAMPLE
starter/pytest.ini (576 bytes)
# The presence of this file is what makes `starter/` the rootdir when you run
# `pytest starter`. pytest walks up from the paths you gave it looking for a
# configuration file (pytest.ini, pyproject.toml, tox.ini, setup.cfg); the
# directory holding the first one it finds becomes the rootdir, and the rootdir
# is what every path in the report is printed relative to.
#
# Without this file pytest would keep walking upward and pick some ancestor
# directory you did not choose, and the report would be full of long paths.
[pytest]
testpaths = .
addopts = -p no:cacheprovider
starter/test_textstats.py (7097 bytes)
"""YOUR test suite for `textstats`. Eight numbered exercises.
Run it from the lab directory, not from here:
pytest starter -v
Exercise 1 is finished, so the file runs the moment you open it. Exercises 2
to 7 each end in a `pytest.skip(...)` line: pytest reports them as `s` in the
dot line and moves on, so an unfinished suite still exits 0. Replace the skip
line with real assertions as you go — deleting the skip is part of the
exercise.
Two of these tests are supposed to FAIL when you first write them correctly.
That is not you making a mistake; that is the suite doing its job on a module
with two real bugs in it. Exercise 8 is where you fix the module.
The specification you are testing against is the docstrings in `textstats.py`.
Read them. Do not read the implementation to decide what the answer should be
— if you copy the expected value out of the code, your test agrees with the
bug and proves nothing.
"""
import pytest
from conftest import SAMPLE
from textstats import (
average_word_length,
reading_time_minutes,
top_words,
word_count,
words,
)
# --------------------------------------------------------------------------
# EXERCISE 1 (worked for you) — the shape of every test in this file.
#
# Arrange: build the inputs. Act: call the thing. Assert: state
# what must be true. Here the arrangement is a literal, so it fits in one
# line, which is normal and good.
#
# Run just this one: pytest starter -v -k splits_on_punctuation
# --------------------------------------------------------------------------
def test_words_splits_on_punctuation_and_lowercases():
assert words("The cat. The hat!") == ["the", "cat", "the", "hat"]
# --------------------------------------------------------------------------
# EXERCISE 2 — the empty case, for `words`.
#
# The docstring says `words("")` is `[]`. Assert exactly that, then delete the
# skip line below. Check with: pytest starter -v -k empty
# --------------------------------------------------------------------------
def test_words_of_empty_text_is_empty():
pytest.skip("exercise 2: assert that words('') equals [], then delete this line")
# --------------------------------------------------------------------------
# EXERCISE 3 — count the sample.
#
# `sample_text` as a parameter asks pytest for the fixture defined in
# `conftest.py`; it arrives as the sentence itself. Its word count is written
# down in `conftest.py`, counted by hand. Assert `word_count(sample_text)`
# equals that number. Check with: pytest starter -v -k word_count
# --------------------------------------------------------------------------
def test_word_count_of_the_sample(sample_text):
pytest.skip("exercise 3: assert the hand-counted word count, then delete this line")
# --------------------------------------------------------------------------
# EXERCISE 4 — the first bug.
#
# The docstring of `average_word_length` says empty text gives 0.0. Assert it.
# This test is EXPECTED TO FAIL against the module as shipped. Read the
# failure report carefully — it names the exception, the line, and the call
# that produced it. Check with: pytest starter -v -k average
# --------------------------------------------------------------------------
def test_average_word_length_of_empty_text_is_zero():
pytest.skip("exercise 4: assert average_word_length('') == 0.0, then delete this line")
# --------------------------------------------------------------------------
# EXERCISE 5 — a value you can verify by hand.
#
# "the cat" is two words of three characters, so the mean is exactly 3.0.
# Assert that. Then add a second assertion for the sample sentence, whose mean
# is 46 characters over 12 words. Because that is 3.8333... you need
# `pytest.approx(3.83)` rather than `== 3.83` — floating-point equality is a
# trap you have already met on Day 70.
# --------------------------------------------------------------------------
def test_average_word_length_of_known_text(sample_text):
pytest.skip("exercise 5: assert 3.0 for 'the cat' and approx(3.83) for the sample")
# --------------------------------------------------------------------------
# EXERCISE 6 — the second bug, inside a class.
#
# pytest collects classes named `Test*` and their `test_*` methods. There is
# no base class and no setUp; `self` is there only because Python methods take
# it. Fill in all four methods from the `top_words` docstring:
#
# top_words("a b a c a b", 2) -> [('a', 3), ('b', 2)]
# top_words("a b a c a b", 9) -> [('a', 3), ('b', 2), ('c', 1)]
# top_words("a b a c a b", 0) -> []
# top_words("", 5) -> []
#
# At least one of these is EXPECTED TO FAIL. Check with:
# pytest starter -v -k TestTopWords
# --------------------------------------------------------------------------
class TestTopWords:
def test_returns_exactly_n_items(self):
pytest.skip("exercise 6a: assert the two-item result, then delete this line")
def test_asking_for_more_than_exist_returns_everything(self):
pytest.skip("exercise 6b: assert all three items come back")
def test_asking_for_zero_returns_nothing(self):
pytest.skip("exercise 6c: assert the result is []")
def test_empty_text_has_no_top_words(self):
pytest.skip("exercise 6d: assert the result is []")
# --------------------------------------------------------------------------
# EXERCISE 7 — an expected exception.
#
# `reading_time_minutes` raises `ValueError` when the speed is not positive.
# Asserting that a call raises is done with a context manager:
#
# with pytest.raises(ValueError, match="must be positive"):
# reading_time_minutes(SAMPLE, 0)
#
# The `match=` argument is a regular expression checked against the message,
# so the test fails if the right exception is raised for the wrong reason.
# Write that test, and add three more covering the rounding rules from the
# docstring: "hello there" -> 1, "word " * 500 -> 3, and "" -> 0.
# --------------------------------------------------------------------------
def test_reading_time_rules():
pytest.skip("exercise 7: assert the rounding rules and the ValueError")
# --------------------------------------------------------------------------
# EXERCISE 8 — go green.
#
# With exercises 2 to 7 written, run: pytest starter -v
# You should see failures from exercise 4 and from exercise 6. Now fix
# `starter/textstats.py` — two lines, one in each broken function — and run
# again until every test passes and the process exits 0:
#
# pytest starter -q
# echo $?
#
# Then prove your suite is not vacuous. Re-break one of the two lines, run
# again, and confirm the exit code is 1. A suite that stays green when the
# code is wrong is worse than no suite, because it is a promise you are not
# keeping.
#
# Finally, add ONE more test of your own for a case none of the above covers.
# Name it `test_...` and make it fail first, on purpose, before you make it
# pass.
# --------------------------------------------------------------------------
starter/textstats.py (3228 bytes)
"""Small text statistics helpers — the module under test.
This module works. Mostly. It was written quickly, checked by eye on one
paragraph of English prose, and shipped. Two of its five public functions are
wrong, and neither is wrong in a way you notice by reading the code once.
Your job in this lab is NOT to read this file until you spot the bugs. Your
job is to write tests that catch them. Read the docstrings — they are the
specification. Then write tests that hold the code to the docstrings.
Do not edit this file until exercise 8 tells you to.
"""
from __future__ import annotations
import math
import re
# A "word" is a run of letters, optionally containing apostrophes, so that
# "don't" is one word and "state-of-the-art" is four.
WORD_PATTERN = re.compile(r"[A-Za-z']+")
def words(text: str) -> list[str]:
"""Split text into lowercase words.
Punctuation and digits are separators. The result is in reading order and
may contain repeats.
words("The cat. The hat!") -> ['the', 'cat', 'the', 'hat']
words("") -> []
"""
return [match.group(0).lower() for match in WORD_PATTERN.finditer(text)]
def word_count(text: str) -> int:
"""Count the words in text.
word_count("The cat. The hat!") -> 4
word_count("") -> 0
"""
return len(words(text))
def average_word_length(text: str) -> float:
"""Mean number of characters per word, rounded to two decimal places.
Empty text has no words, so the average is defined to be 0.0 — asking for
the mean of nothing is not an error, it is just nothing.
average_word_length("the cat") -> 3.0
average_word_length("") -> 0.0
"""
found = words(text)
total_characters = sum(len(word) for word in found)
return round(total_characters / len(found), 2)
def top_words(text: str, n: int) -> list[tuple[str, int]]:
"""The n most frequent words, most frequent first.
Ties are broken alphabetically so the result is deterministic. Asking for
more words than exist returns everything. Asking for zero returns nothing.
top_words("a b a c a b", 2) -> [('a', 3), ('b', 2)]
top_words("a b a c a b", 9) -> [('a', 3), ('b', 2), ('c', 1)]
top_words("a b a c a b", 0) -> []
"""
counts: dict[str, int] = {}
for word in words(text):
counts[word] = counts.get(word, 0) + 1
ranked = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
return ranked[: n - 1]
def reading_time_minutes(text: str, words_per_minute: int = 200) -> int:
"""Whole minutes needed to read text, always rounded up.
Any text at all takes at least one minute; empty text takes none. The
reading speed must be a positive number of words per minute.
reading_time_minutes("hello there") -> 1
reading_time_minutes("word " * 500) -> 3
reading_time_minutes("word " * 500, 100) -> 5
reading_time_minutes("") -> 0
"""
if words_per_minute <= 0:
raise ValueError(f"words_per_minute must be positive, got {words_per_minute}")
return math.ceil(word_count(text) / words_per_minute)
tests/run_tests.sh (17151 bytes)
#!/usr/bin/env bash
# Tests for the Day 071 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# This suite is unusual: it is a test suite ABOUT a test suite. Its job is to
# prove that the pytest suite in examples/ is not decorative. Three of the
# checks below do that directly, and they are the ones worth reading:
#
# * "a broken implementation makes the suite fail" copies the fixed module
# to a temporary directory, breaks exactly one line with sed, and asserts
# that pytest exits NON-ZERO. A suite that stays green when the code is
# wrong is worse than no suite at all;
# * "the reference suite catches both shipped bugs" runs the reference tests
# against the buggy starter module and asserts it fails on precisely the
# two functions that are wrong;
# * "an empty directory exits 5" pins pytest's own exit-code contract, which
# is the thing continuous integration actually reads.
#
# No network, non-interactive, deterministic. Exits 0 only if every check
# passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# 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
}
python_bin="$(command -v python3 || true)"
if [ -z "${python_bin}" ]; then
echo "FAIL: python3 not found on PATH." >&2
exit 1
fi
echo "Day 071 — Your First Real Test Suite"
echo
# --------------------------------------------------------------------------
echo "1. The tool itself"
# --------------------------------------------------------------------------
version_line="$("${pytest_bin}" --version 2>&1 | head -1)"
case "${version_line}" in
pytest*) check "pytest --version reports a pytest ( ${version_line} )" "yes" ;;
*) check "pytest --version reports a pytest ( ${version_line} )" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "2. The reference suite passes"
# --------------------------------------------------------------------------
examples_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q 2>&1)"
examples_exit=$?
if [ "${examples_exit}" -eq 0 ]; then
check "pytest examples exits 0" "yes"
else
check "pytest examples exits 0 (got ${examples_exit})" "no"
echo "${examples_out}" | tail -20
fi
case "${examples_out}" in
*"19 passed"*) check "pytest examples reports 19 passed" "yes" ;;
*) check "pytest examples reports 19 passed" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "3. Collection: rootdir, test ids, and selection"
# --------------------------------------------------------------------------
collected="$(cd "${lab_dir}" && "${pytest_bin}" examples --collect-only -q 2>&1)"
for test_id in \
"test_textstats.py::test_words_of_empty_text_is_empty" \
"test_textstats.py::test_average_word_length_of_empty_text_is_zero" \
"test_textstats.py::TestTopWords::test_returns_exactly_n_items" \
"test_textstats.py::test_reading_time_rejects_a_non_positive_speed"
do
case "${collected}" in
*"${test_id}"*) check "collection finds ${test_id}" "yes" ;;
*) check "collection finds ${test_id}" "no" ;;
esac
done
case "${collected}" in
*"19 tests collected"*) check "collection finds exactly 19 tests" "yes" ;;
*) check "collection finds exactly 19 tests" "no" ;;
esac
# `Test*` classes are collected too, and their ids carry the class name.
class_ids="$(printf '%s\n' "${collected}" | grep -c '::TestTopWords::' || true)"
if [ "${class_ids}" -eq 5 ]; then
check "the TestTopWords class contributes 5 collected tests" "yes"
else
check "the TestTopWords class contributes 5 collected tests (got ${class_ids})" "no"
fi
selected="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -k "top_words or TestTopWords" 2>&1)"
case "${selected}" in
*"5 passed, 14 deselected"*) check "-k 'top_words or TestTopWords' selects 5 of 19" "yes" ;;
*) check "-k 'top_words or TestTopWords' selects 5 of 19" "no" ;;
esac
reading_selected="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -k "reading_time" 2>&1)"
case "${reading_selected}" in
*"5 passed, 14 deselected"*) check "-k reading_time selects the 5 reading-time tests" "yes" ;;
*) check "-k reading_time selects the 5 reading-time tests" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "4. Failure reporting and assertion rewriting"
# --------------------------------------------------------------------------
failure_out="$(cd "${lab_dir}" && "${pytest_bin}" examples/failure-demo 2>&1)"
failure_exit=$?
if [ "${failure_exit}" -eq 1 ]; then
check "the deliberate-failure demo exits 1" "yes"
else
check "the deliberate-failure demo exits 1 (got ${failure_exit})" "no"
fi
case "${failure_out}" in
*"assert 4 == 3"*) check "assertion rewriting shows both sides: 'assert 4 == 3'" "yes" ;;
*) check "assertion rewriting shows both sides: 'assert 4 == 3'" "no" ;;
esac
case "${failure_out}" in
*"where 4 = add(1, 2)"*) check "the report explains where the 4 came from" "yes" ;;
*) check "the report explains where the 4 came from" "no" ;;
esac
case "${failure_out}" in
*"At index 4 diff:"*) check "a list mismatch names the index that differs" "yes" ;;
*) check "a list mismatch names the index that differs" "no" ;;
esac
case "${failure_out}" in
*"DID NOT RAISE ValueError"*) check "pytest.raises reports a missing exception" "yes" ;;
*) check "pytest.raises reports a missing exception" "no" ;;
esac
case "${failure_out}" in
*"short test summary info"*) check "the short test summary lists every failure" "yes" ;;
*) check "the short test summary lists every failure" "no" ;;
esac
stop_first="$(cd "${lab_dir}" && "${pytest_bin}" examples/failure-demo -x -q --tb=short 2>&1)"
case "${stop_first}" in
*"1 failed"*) check "-x stops after the first failure" "yes" ;;
*) check "-x stops after the first failure" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "5. Exit codes — the contract continuous integration reads"
# --------------------------------------------------------------------------
empty_dir="$(mktemp -d "${TMPDIR:-/tmp}/pytest-empty.XXXXXX")"
(cd "${empty_dir}" && "${pytest_bin}" . -q >/dev/null 2>&1)
empty_exit=$?
rm -rf "${empty_dir}"
if [ "${empty_exit}" -eq 5 ]; then
check "a run that collects no tests exits 5, not 0" "yes"
else
check "a run that collects no tests exits 5, not 0 (got ${empty_exit})" "no"
fi
# --------------------------------------------------------------------------
echo
echo "6. The suite actually tests something (the checks that matter)"
# --------------------------------------------------------------------------
# 6a. Control: the fixed module plus the reference suite is green.
work="$(mktemp -d "${TMPDIR:-/tmp}/pytest-break.XXXXXX")"
cp "${lab_dir}/examples/textstats.py" "${lab_dir}/examples/test_textstats.py" \
"${lab_dir}/examples/conftest.py" "${lab_dir}/examples/pytest.ini" "${work}/"
(cd "${work}" && "${pytest_bin}" . -q >/dev/null 2>&1)
control_exit=$?
if [ "${control_exit}" -eq 0 ]; then
check "control: the fixed module passes the reference suite (exit 0)" "yes"
else
check "control: the fixed module passes the reference suite (got ${control_exit})" "no"
fi
# 6b. Break exactly one line and demand a non-zero exit.
sed -i.bak 's/return len(words(text))/return len(words(text)) + 1/' "${work}/textstats.py"
rm -f "${work}/textstats.py.bak"
if grep -q 'return len(words(text)) + 1' "${work}/textstats.py"; then
check "the sed edit really changed word_count" "yes"
else
check "the sed edit really changed word_count" "no"
fi
broken_out="$(cd "${work}" && "${pytest_bin}" . -q 2>&1)"
broken_exit=$?
if [ "${broken_exit}" -ne 0 ]; then
check "a one-line break makes the suite FAIL (exit ${broken_exit}, not 0)" "yes"
else
check "a one-line break makes the suite FAIL — it did not, so the suite is vacuous" "no"
fi
case "${broken_out}" in
*"test_word_count_of_the_sample_is_twelve"*)
check "the failing run names test_word_count_of_the_sample_is_twelve" "yes" ;;
*) check "the failing run names test_word_count_of_the_sample_is_twelve" "no" ;;
esac
rm -rf "${work}"
# 6c. The reference suite catches both bugs shipped in starter/textstats.py.
buggy="$(mktemp -d "${TMPDIR:-/tmp}/pytest-buggy.XXXXXX")"
cp "${lab_dir}/starter/textstats.py" "${buggy}/"
cp "${lab_dir}/examples/test_textstats.py" "${lab_dir}/examples/conftest.py" \
"${lab_dir}/examples/pytest.ini" "${buggy}/"
buggy_out="$(cd "${buggy}" && "${pytest_bin}" . -q 2>&1)"
buggy_exit=$?
if [ "${buggy_exit}" -ne 0 ]; then
check "the reference suite rejects the buggy starter module" "yes"
else
check "the reference suite rejects the buggy starter module" "no"
fi
case "${buggy_out}" in
*"test_average_word_length_of_empty_text_is_zero"*)
check "bug 1 caught: average_word_length divides by zero on empty text" "yes" ;;
*) check "bug 1 caught: average_word_length divides by zero on empty text" "no" ;;
esac
case "${buggy_out}" in
*"TestTopWords::test_returns_exactly_n_items"*)
check "bug 2 caught: top_words is off by one" "yes" ;;
*) check "bug 2 caught: top_words is off by one" "no" ;;
esac
case "${buggy_out}" in
*"4 failed, 15 passed"*)
check "exactly 4 of the 19 reference tests fail on the buggy module" "yes" ;;
*) check "exactly 4 of the 19 reference tests fail on the buggy module" "no" ;;
esac
rm -rf "${buggy}"
# 6d. The two repairs are exactly where the lesson says they are, and the
# starter really does still carry both bugs.
if grep -q 'return ranked\[: n - 1\]' "${lab_dir}/starter/textstats.py"; then
check "starter/textstats.py still carries the off-by-one slice" "yes"
else
check "starter/textstats.py still carries the off-by-one slice" "no"
fi
if grep -q 'return ranked\[:n\]' "${lab_dir}/examples/textstats.py"; then
check "examples/textstats.py repairs the slice to ranked[:n]" "yes"
else
check "examples/textstats.py repairs the slice to ranked[:n]" "no"
fi
if grep -q 'if not found:' "${lab_dir}/examples/textstats.py"; then
check "examples/textstats.py guards the empty-text division" "yes"
else
check "examples/textstats.py guards the empty-text division" "no"
fi
if grep -q 'if not found:' "${lab_dir}/starter/textstats.py"; then
check "starter/textstats.py still lacks the empty-text guard" "no"
else
check "starter/textstats.py still lacks the empty-text guard" "yes"
fi
# 6e. The vacuous-test demonstration behaves exactly as the lesson claims:
# four green tests on a knowingly broken function, and one honest test
# that catches it. prove_it.sh exits 0 only if both halves hold.
if (cd "${lab_dir}" && PYTEST="${pytest_bin}" bash examples/vacuous-demo/prove_it.sh >/dev/null 2>&1); then
check "four vacuous tests stay green on broken code; the honest one fails" "yes"
else
check "four vacuous tests stay green on broken code; the honest one fails" "no"
fi
vacuous_out="$(cd "${lab_dir}" && "${pytest_bin}" examples/vacuous-demo -q 2>&1)"
case "${vacuous_out}" in
*"5 passed"*) check "all 5 vacuous-demo tests pass on the CORRECT module" "yes" ;;
*) check "all 5 vacuous-demo tests pass on the CORRECT module" "no" ;;
esac
# `pytest examples` must not wander into either deliberately-wrong directory.
case "${examples_out}" in
*vacuous*|*failure*) check "norecursedirs keeps the wrong-on-purpose dirs out of 'pytest examples'" "no" ;;
*) check "norecursedirs keeps the wrong-on-purpose dirs out of 'pytest examples'" "yes" ;;
esac
# --------------------------------------------------------------------------
echo
echo "7. The starter is runnable before you start"
# --------------------------------------------------------------------------
starter_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q 2>&1)"
starter_exit=$?
if [ "${starter_exit}" -eq 0 ]; then
check "pytest starter exits 0 with the exercises unfinished" "yes"
else
check "pytest starter exits 0 with the exercises unfinished (got ${starter_exit})" "no"
fi
case "${starter_out}" in
*"1 passed, 9 skipped"*) check "the starter has 1 worked test and 9 skipped exercises" "yes" ;;
*) check "the starter has 1 worked test and 9 skipped exercises" "no" ;;
esac
starter_collected="$(cd "${lab_dir}" && "${pytest_bin}" starter --collect-only -q 2>&1)"
case "${starter_collected}" in
*"10 tests collected"*) check "the starter collects 10 tests" "yes" ;;
*) check "the starter collects 10 tests" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "8. Testing without pytest: assert, unittest, doctest"
# --------------------------------------------------------------------------
if (cd "${lab_dir}" && "${python_bin}" examples/plain_asserts.py >/dev/null 2>&1); then
check "examples/plain_asserts.py exits 0 — bare asserts are already a suite" "yes"
else
check "examples/plain_asserts.py exits 0 — bare asserts are already a suite" "no"
fi
# The same file, with the module broken, must exit non-zero.
plain="$(mktemp -d "${TMPDIR:-/tmp}/plain-assert.XXXXXX")"
cp "${lab_dir}/examples/textstats.py" "${lab_dir}/examples/plain_asserts.py" "${plain}/"
sed -i.bak 's/return len(words(text))/return len(words(text)) + 1/' "${plain}/textstats.py"
rm -f "${plain}/textstats.py.bak"
if (cd "${plain}" && "${python_bin}" plain_asserts.py >/dev/null 2>&1); then
check "a broken module makes the bare-assert script exit non-zero" "no"
else
check "a broken module makes the bare-assert script exit non-zero" "yes"
fi
rm -rf "${plain}"
if (cd "${lab_dir}" && "${python_bin}" examples/unittest_demo.py >/dev/null 2>&1); then
check "examples/unittest_demo.py passes under the stdlib runner" "yes"
else
check "examples/unittest_demo.py passes under the stdlib runner" "no"
fi
unittest_fail_out="$(cd "${lab_dir}" && "${python_bin}" examples/failure-demo/unittest_failure.py 2>&1)"
unittest_fail_exit=$?
if [ "${unittest_fail_exit}" -ne 0 ]; then
check "the unittest failure demo exits non-zero" "yes"
else
check "the unittest failure demo exits non-zero" "no"
fi
case "${unittest_fail_out}" in
*"AssertionError: 4 != 3"*)
check "unittest's assertEqual reports both sides" "yes" ;;
*) check "unittest's assertEqual reports both sides" "no" ;;
esac
# The bare assert inside unittest gets NO values — nothing rewrote it.
if printf '%s\n' "${unittest_fail_out}" \
| grep -A2 'test_a_bare_assert_inside_unittest' >/dev/null 2>&1; then
bare_detail="$(printf '%s\n' "${unittest_fail_out}" | grep -c '^AssertionError$' || true)"
if [ "${bare_detail}" -ge 1 ]; then
check "a bare assert under unittest reports no values at all" "yes"
else
check "a bare assert under unittest reports no values at all" "no"
fi
else
check "a bare assert under unittest reports no values at all" "no"
fi
doctest_out="$(cd "${lab_dir}" && "${python_bin}" examples/doctest_demo.py 2>&1)"
doctest_exit=$?
if [ "${doctest_exit}" -eq 0 ]; then
check "examples/doctest_demo.py exits 0" "yes"
else
check "examples/doctest_demo.py exits 0" "no"
fi
case "${doctest_out}" in
*"8 examples attempted, 0 failed"*) check "doctest runs all 8 documented examples" "yes" ;;
*) check "doctest runs all 8 documented examples (got: ${doctest_out})" "no" ;;
esac
if (cd "${lab_dir}" && "${python_bin}" -m doctest examples/doctest_demo.py >/dev/null 2>&1); then
check "python3 -m doctest agrees, and says nothing when all is well" "yes"
else
check "python3 -m doctest agrees, and says nothing when all is well" "no"
fi
# --------------------------------------------------------------------------
echo
echo "9. Nothing here touches the network or the clock"
# --------------------------------------------------------------------------
if grep -rqE 'import (socket|urllib|requests|http)|datetime\.now|time\.time|random\.' \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no network, clock or randomness in examples/ or starter/" "no"
else
check "no network, clock or randomness in examples/ or starter/" "yes"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 071 lab
Every symptom below was produced on purpose while building this lab. The messages are quoted from real runs.
pytest: command not found
pytest is not on your PATH. That is normal — a virtual environment keeps it
out of the way deliberately. Either use the lab's copy explicitly:
.venv/bin/pytest examples
or install it first:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
The test runner does not need pytest on PATH at all; it looks for
.venv/bin/pytest inside this lab before it looks anywhere else.
FAIL: pytest not found. from bash tests/run_tests.sh
The runner checked three places and found nothing: the PYTEST environment
variable, .venv/bin/pytest, and PATH. Follow the install instructions it
printed, or point it at a pytest you already have:
PYTEST=/full/path/to/pytest bash tests/run_tests.sh
The runner stops here rather than reporting success on nothing. A suite that skips itself and exits 0 is exactly the failure this lab teaches you to distrust.
no tests ran in 0.00s and exit code 5
pytest collected nothing. Almost always one of three things:
- you named a path that does not exist, or that contains no
test_*.pyfile; - your test file is not named
test_something.py; - your test functions are not named
test_something.
Check what pytest can see before you debug anything else:
pytest examples --collect-only -q
Exit code 5 is not zero, so a correctly written build script fails on it. A
script that only looks for the word FAILED will happily ship.
ERROR collecting test_textstats.py / import file mismatch
You ran pytest starter examples, or pytest with no arguments from the lab
directory. The real message:
import file mismatch:
imported module 'test_textstats' has this __file__ attribute:
.../starter/test_textstats.py
which is not the same as the test file we want to collect:
.../examples/test_textstats.py
HINT: remove __pycache__ / .pyc files and/or use a unique basename for your
test file modules
This is worth understanding rather than working around. pytest imports each
test file as a Python module named after the file. Two files called
test_textstats.py in two directories both want to be the module
test_textstats, and Python keeps exactly one module of a given name in
memory. The lab has two on purpose — a buggy one you are testing and a fixed
reference — so run one directory at a time:
pytest starter
pytest examples
Real projects avoid this by putting an __init__.py beside their tests (which
makes the module name include the package path) or by giving every test file a
distinct name. Exit code here is 2: interrupted, not failed.
ModuleNotFoundError: No module named 'textstats'
You ran pytest from inside starter/ or examples/, or from the repository
root. Run it from the lab directory and name the directory as an argument:
cd labs/sections/programming-with-python/day-071-why-test-and-pytest-basics
pytest starter
pytest inserts a test file's own directory at the front of sys.path before
importing it, which is what makes the bare from textstats import ... at the
top of the test file work. Give it the wrong starting point and the import
fails.
ModuleNotFoundError: No module named 'conftest'
Same cause, same fix. conftest.py is imported by pytest automatically, but
from conftest import SAMPLE is an ordinary import that needs the directory on
sys.path — which pytest arranges only when it is collecting from that
directory.
fixture 'sample_text' not found
Three possibilities:
- you are running from a directory where
conftest.pyis not a parent of the test file; - you renamed or moved
conftest.py; - you spelled the parameter differently from the fixture function's name. The match is by name and nothing else.
List what pytest can see:
pytest starter --fixtures | head -40
My test fails and I think it should pass
Read the report from the bottom up. The short test summary names the test; the
E lines show both sides of the comparison. For example:
E assert 4 == 3
E + where 4 = add(1, 2)
The left value is what your code produced; the right is what you asserted. The
where line reconstructs the call that produced it. If the two sides look
identical but the test still fails, you are almost certainly comparing floats —
use pytest.approx. Day 70's warning about 0.1 + 0.2 is the same warning.
Two tests fail and I did not write a bug
Correct. starter/textstats.py ships with two real bugs, and exercises 4 and 6
are supposed to catch them. That is the lab. Fix the module in exercise 8.
ZeroDivisionError: division by zero
That is bug 1, in average_word_length. Empty text has no words, so
len(found) is 0. The docstring says the answer is 0.0, not a crash.
Everything passes but I do not believe it
Good. Prove it, the way the test runner does:
bash examples/vacuous-demo/prove_it.sh
Then do it to your own work: break one line of starter/textstats.py, run
pytest starter, and confirm the run goes red and the exit code is 1. Undo the
break. A suite you have never seen fail is a suite you have never tested.
__pycache__ and .pytest_cache directories appeared
__pycache__ is Python's compiled-bytecode cache and is always safe to delete.
.pytest_cache is pytest's own; this lab disables it via addopts = -p no:cacheprovider in both pytest.ini files, so you should not see one.
To clean up:
export PYTHONDONTWRITEBYTECODE=1 # prevents it happening again in this shell
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
The plugins: line in my output differs from the captures
Expected. That line lists whatever pytest plugins happen to be installed
alongside pytest. A clean lab .venv created from requirements.txt shows no
plugins: line at all; the authoring machine had two unrelated plugins
present. Nothing in this lab depends on any plugin.
Colours, or the lack of them
pytest colours its output when it detects a terminal and drops the colour when
output is redirected to a file — which is why the captures in
expected-output/ are plain. Force it either way with --color=yes or
--color=no.
Windows
Use WSL and follow the Linux instructions. Without WSL, the virtual
environment's tools are at .venv\Scripts\pytest rather than
.venv/bin/pytest, python may be spelled python rather than python3, and
tests/run_tests.sh needs a bash — Git Bash or WSL. The pytest commands
themselves are identical.
Security notes
Security notes — Day 071 lab
What this lab does to your machine
It creates a virtual environment in .venv/, installs one package into it,
reads text files, and runs Python in temporary directories made with
mktemp -d and removed when each check finishes. It writes nothing outside
this lab directory and the system temporary directory. It does not need sudo
at any point, and if a command ever asks you for a password, something is
wrong — stop and read it.
The network moment
Exactly one command in this lab touches the network:
.venv/bin/pip install -r requirements/requirements.txt
That downloads pytest and its four small dependencies from the Python Package
Index. After it finishes, every other command in the lab runs fully offline —
the test suite included. metadata.yml therefore records
requires_network: true, which is honest about the install even though the
tests themselves never open a socket.
Two habits worth forming now, because installing packages is where most Python supply-chain incidents begin:
- Pin versions.
pytest==9.1.1, neverpytest. An unpinned dependency means the code you audited on Monday is not the code that runs on Friday. - Read the name you are typing. Typo-squatting — a package whose name is
one character away from a popular one — is a live and ordinary attack. The
package you want here is
pytest, spelled exactly that way.
Installing into a virtual environment, not the system Python
python3 -m venv .venv gives this lab its own copy of pip and its own
site-packages. Two reasons that matters beyond tidiness:
- a package installed here cannot overwrite or shadow anything your operating system depends on;
- deleting the directory removes every trace of it, with no uninstall step and nothing left in a system path.
Never install a lab's requirements with sudo pip install. Any code in any
package can run at install time.
Test code is code
This is the security lesson of the day, and it is routinely forgotten.
pytest imports every file it collects. Importing runs the module body. So
a file called test_anything.py dropped into a directory you run pytest over
executes with your privileges, before a single assertion is evaluated. The
same is true of conftest.py, which pytest imports automatically without any
file naming it. Consequences:
- treat test files from an untrusted source exactly like any other code you are about to run — read them first;
- be careful what you point pytest at.
pytest /some/downloaded/directoryimports everything test-shaped underneath it; - a
conftest.pyis the quietest place in a Python repository to hide code that runs on every test invocation. When you review a pull request, read theconftest.pydiff as carefully as the source diff.
Nothing in this lab does anything of the sort — every file here is short enough to read in a minute, and you should.
Assertions and python -O
Python's -O flag strips assert statements from compiled code entirely.
That is why assert is a fine tool for tests, which are never run optimised,
and a poor tool for runtime validation of untrusted input, which may be. A
security check written as assert user.is_admin disappears under -O and the
program carries on as if it had passed. Validate with an explicit if and a
raised exception, exactly as Day 70's domain model does; keep assert for
tests.
Secrets
This lab has no credentials, no API keys, and no configuration file that would hold any. When you later write tests for code that does, three rules:
- never put a real credential in a test file — test files are committed, and committed secrets are permanently leaked even after the commit is amended;
- never test against production data or a production service;
- redact before you print. A failing assertion prints both sides of the comparison, so a test that asserts on an object containing a token will put that token in a build log, and build logs are widely readable.
Day 074 makes this concrete: you will stub the outside boundary rather than call it, which removes the credential from the test entirely.
What the test runner does with temporary files
tests/run_tests.sh copies small files into directories created with
mktemp -d, breaks one line with sed, runs pytest there, and deletes the
directory. It never edits a file inside the lab, so a failing run cannot
corrupt your work. Every destructive command it issues names an exact path it
just created — there is no wildcard deletion anywhere in it.
Privacy
No telemetry. pytest does not phone home, does not require an account, and collects nothing. The only data leaving your machine in this lab is the HTTPS request pip makes for the package itself.