Testing and typing
Writing Your First pytest Suite
Install pytest in a virtualenv, learn the discovery rules, and use plain asserts, pytest.raises, fixtures, parametrisation and subset runs to build a real suite.
The first test file is easy. The second one is where people get stuck, because the questions are no longer about Python. Where does the file go? Why did pytest collect nothing? Why did it collect a helper function by accident? Where does the fixture you wrote last week live now that three files need it?
pytest answers all of these with conventions rather than configuration, which is a gift once you know the conventions and a mystery until then. This lesson puts the conventions in one place, and shows you a real failing run so you can see what you are paying for. The output below is from pytest 9.1.1 on Python 3.14.7.
Install pytest inside the project, not into the system
A test runner is a development dependency. It belongs in the same virtual environment as the code it tests, so that the pytest version is pinned per project and a change to pytest cannot break an unrelated script on your machine. From the project directory:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pytest
On Windows the activation line is .venv\Scripts\activate. Everything
after it is identical. The install ends with a line naming what it put on disk:
Successfully installed iniconfig-2.3.1 packaging-26.3 pluggy-1.7.0 pygments-2.21.0 pytest-9.1.1
Confirm the runner answers to the interpreter sitting in front of you:
python -m pytest --version
pytest 9.1.1
Run pytest as python -m pytest rather than bare pytest.
Both work when the virtualenv is active, but the module form guarantees you are using
the interpreter in front of you, which is the entire point of the virtualenv. It also
puts the current directory on sys.path, so a flat project with
durations.py next to tests/ imports cleanly without an
installable package.
Discovery is three naming rules, not a config file
With no configuration at all, pytest recurses from the directory you invoke it in and collects:
- Files named
test_*.pyor*_test.py. - Classes named
Test*, as long as they have no__init__method. - Functions and methods named
test_*inside those files and classes.
The trap is the boundary of each rule. A file called
check_durations.py is invisible no matter what is inside it. A method
named test_inside_a_non_test_class is invisible when its class is called
CheckClass. A function called check_something is invisible
even in a file pytest does collect. I confirmed all three of those negative cases on a
scratch directory before writing this, and the run collected one test out of the four
I had written:
python -m pytest tmpdisco --collect-only -q
tmpdisco/test_neg.py::TestCollected::test_this_one_is_collected
1 test collected in 0.00s
When discovery surprises you, --collect-only is the tool. It prints
every node ID pytest would run and nothing else, so a file that failed to import and a
file that was never considered look different immediately.
A file that pytest does collect but cannot import is an error, not a skip. If
tests/test_broken_import.py contains import does_not_exist,
the run stops before executing anything:
ERROR tmpdisco/test_broken_import.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.07s
That is the behaviour you want. A typo in an import silently skipping an entire test module is how a suite of forty tests quietly becomes a suite of twelve.
A plain assert is enough because pytest rewrites it
You do not write assertEqual or assertThat. You write
Python's own assert. The reason that is not a downgrade is that pytest
rewrites the bytecode of every test module it imports, replacing each assertion with a
version that remembers the left and right values and prints them when the assertion
fails.
The difference is stark. Run this line as an ordinary script and Python tells you nothing you did not already know:
from durations import parse_duration
assert parse_duration("2h") == 2 * 3600
Traceback (most recent call last):
File "bare_assert.py", line 3, in <module>
assert parse_duration("2h") == 2 * 3600
AssertionError
Put the same expression in a test function and pytest shows you the two values it compared and the call that produced one of them:
def test_compound_expression():
assert parse_duration("2h") == 2 * 3600
def test_compound_expression():
> assert parse_duration("2h") == 2 * 3600
E AssertionError: assert 720 == (2 * 3600)
E + where 720 = parse_duration('2h')
tests/test_durations.py:5: AssertionError
Read the where line as a footnote to the comparison. The assertion is
false because the left side is 720, and the left side is 720 because
parse_duration('2h') returned it. You now know which function to open and
which input reproduces the problem, which is more than a bare
AssertionError ever gives you.
Rewriting is not the same as assert being disabled
Running Python with -O strips every assert statement
from the code it executes, which is why nobody uses assert for input
validation in production. pytest installs its own rewritten asserts into every test
module it imports, so a suite still fails with the full diff when you run it as
python -O -m pytest. I checked that: the failure output was identical.
What pytest cannot rescue is an assertion in code it did not rewrite, and it prints a
warning naming the case rather than letting you believe the suite is clean.
pytest.raises asserts that code fails on purpose
A function that is supposed to reject bad input needs a test that proves it rejects bad input. If you call it and it raises, the exception escapes and pytest reports an error, so the test cannot call the function bare. Wrap the call:
import pytest
from durations import parse_duration
def test_rejects_an_unknown_unit():
with pytest.raises(ValueError, match="unknown unit"):
parse_duration("5x")
The context manager asserts that the block raises ValueError. The
match argument is a regular expression tested against the exception's
string form, and it is worth using, because ValueError alone does not
distinguish "unknown unit" from "trailing number without a unit". Without it, a bug
that raises the wrong ValueError keeps the test green.
Two failure modes you will meet. If nothing is raised, pytest reports the block itself:
def test_rejects_an_unknown_unit():
> with pytest.raises(ValueError):
^^^^^^^^^^^^^^^^^^^^^^^^^
E Failed: DID NOT RAISE ValueError
tests/test_durations.py:19: Failed
If the block raises something else, pytest still fails, with the unexpected exception's traceback attached. Neither failure is silent, which is the property you want from a test of a rejection path.
Fixtures move shared setup into the signature
Setup code that several tests need goes in a function decorated with
@pytest.fixture, and a test asks for it by naming it as a parameter.
pytest matches the parameter name to the fixture, calls the fixture, and passes the
return value in. Put the fixture in tests/conftest.py:
import pytest
@pytest.fixture
def week():
return {"on_call": 12, "standup": 5}
Then use it in tests/test_durations.py by naming it. There is no import
and no decorator on the test, which is the point: the parameter name is the whole
contract.
def test_totals_a_list_of_durations(week):
assert sum(parse_duration(f"{hours}h") for hours in week.values()) == 61200
Fixtures become genuinely useful when the setup involves a resource. pytest ships
tmp_path, a fixture that hands you a fresh directory unique to the test
and cleans it up afterwards, so tests that write files never collide with each other or
with your working copy:
@pytest.fixture
def csv_file(tmp_path):
path = tmp_path / "basket.csv"
path.write_text("sku,quantity\nA-1,2\n")
return path
A fixture that needs to tear something down uses yield instead of
return: everything before the yield is setup, everything
after it is teardown, and the teardown runs even when the test fails. That is why
fixtures replaced the old setUp/tearDown pair for most
purposes.
A fixture defined inside a test file is visible only to tests in that file. Move it
to tests/conftest.py and every test file in tests/ and its
subdirectories can use it without an import. conftest.py is imported by
pytest, not by your tests, and pytest never collects it as a test module, so a function
named test_something inside it does not run.
Parametrisation turns one test into a table
Copying a test five times to cover five inputs means five places to edit when the
expectation changes, and five names that all start with test_ and none of
which say what they check. @pytest.mark.parametrize keeps one body and
gives each case its own node ID:
@pytest.mark.parametrize(
("text", "seconds"),
[("1s", 1), ("2m", 120), ("1h30m", 5400), ("1d", 86400)],
)
def test_known_durations(text, seconds):
assert parse_duration(text) == seconds
The first argument names the parameters the test receives. Passing a tuple of names
pairs each name with a column of the table; a single string like
"text" works when the rows are single values. pytest builds the node ID
from the arguments, so the collected suite reads as a list of concrete cases:
tests/test_durations.py::test_known_durations[1s-1]
tests/test_durations.py::test_known_durations[2m-120]
tests/test_durations.py::test_known_durations[1h30m-5400]
tests/test_durations.py::test_known_durations[1d-86400]
Those IDs are addresses. When [1h30m-5400] fails, you paste that ID
onto the command line and run that case alone.
Run the subset you mean, and nothing else
Once the suite takes more than a few seconds you stop running all of it. Four invocations cover almost every day:
python -m pytest tests/test_durations.py::test_known_durations # one function, all its cases
python -m pytest -k known # name contains "known"
python -m pytest -x # stop at the first failure
python -m pytest --lf # only what failed last time
-k matches against the test name and works on substrings, so it is
convenient and occasionally too broad: -k known on this suite selects five
of the seven tests, because test_rejects_an_unknown_unit also contains the
letters. The two deselected tests are reported, not hidden, which is how you notice the
mismatch.
5 passed, 2 deselected in 0.01s
--lf depends on a cache directory that pytest writes next to your
configuration, and it is the reason a red suite is not a blocker for a short feedback
loop: run everything once, fix one thing, run the one test that was failing. Get into
the habit of running the whole suite before you commit, though. --lf
answers "is my fix correct", not "is the code correct".
Create a virtualenv, install pytest, and write a top_words(text,
limit=3) function in a module at the project root. It returns the most
common words in a string as (word, count) pairs, lowercased, with
punctuation stripped and ties broken by first appearance.
Then write tests/test_wordcount.py with four tests: one that counts
words in a sentence, one that respects the limit, one that catches an empty string,
and one that asserts a limit below 1 raises ValueError. Put a fixture
that returns a sample sentence in tests/conftest.py and use it in the
first test. Run the suite, then break the function on purpose and run it again.
Answer two questions before you finish: which of the four tests would still pass
if top_words ignored its limit argument, and what does
--collect-only print for your file?
Show the solution and the reasoning
The module keeps the parsing rule in one place so a later change to what counts as a word does not require touching every test:
import re
from collections import Counter
WORD = re.compile(r"[^\W\d_]+", re.UNICODE)
def top_words(text, limit=3):
if limit < 1:
raise ValueError("limit must be at least 1")
counts = Counter(WORD.findall(text.lower()))
return counts.most_common(limit)
[^\W\d_] reads as "not a non-word character, not a digit, not an
underscore", which is a word character excluding digits. The re.UNICODE
flag and the str.lower() call are what make CAFÉ and
café the same word. The Counter gives you the counts, and
most_common sorts by count, breaking ties by insertion order, which is
the tie rule the lab asked for.
The fixture goes in tests/conftest.py so a second test file can use
it later:
import pytest
@pytest.fixture
def sample():
return "the quick brown fox jumps over the lazy dog"
import pytest
from wordcount import top_words
def test_counts_words_in_order(sample):
assert top_words(sample) == [("the", 2), ("quick", 1), ("brown", 1)]
def test_respects_the_limit(sample):
assert len(top_words(sample, limit=2)) == 2
def test_empty_text_has_no_words():
assert top_words("") == []
def test_rejects_a_limit_below_one():
with pytest.raises(ValueError):
top_words("text", limit=0)
On the first question: test_respects_the_limit is the only test that
fails if limit is ignored, and only because it asserts a length rather
than an exact result. If it had been written as
assert top_words(sample, limit=2)[0] == ("the", 2) it would pass with
the bug present. The empty-string test would also pass with the limit ignored. That
is the general shape of the problem this module's next lesson is about, and it is
worth noticing that the bug you can imagine here is invisible to three of your four
tests.
--collect-only prints four node IDs, one per test, with no
parametrisation in this file. If you add a @pytest.mark.parametrize with
four rows, the same command prints seven IDs: three plain tests and four cases.
Two details in this answer are deliberate and worth copying. The fixture is
requested by name only in the tests that use it, so the two tests that do not need a
sentence do not pay for one. And the ValueError test does not use
match=, which is acceptable here because there is only one
ValueError the function can raise. Once a second one exists, add the
pattern or the test stops distinguishing them.
Progress
Mark this lesson done and it stays marked in this browser.