Skip to content
System nominalUTC --:--:--Articles 041
MODULE 05 Lesson 13 / 18 30 MIN

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_*.py or *_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".

Lab 30 minutes

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.