Chapter 18 — Testing Basics: Checking Code Automatically with pytest

Series I1 — Tidy and Maintainable Code

NoteIn this chapter, you will:
  • Understand why automatic tests save you when code changes
  • Write assert statements and your first test function
  • Run pytest and read the passed and failed report
  • Use fixtures (ready-made ingredients), parametrize, and pytest.raises
  • Lay out a test folder and test the inventory package from the running project

All scripts for this chapter are in the kode/ch18/ folder. On the web version, you can also download them all at once (zip).

Imagine you change the biomass formula in the Pond class. Do the pen, the rice field, and the forest plot still give correct numbers? Checking everything by eye after each change is tiring, and something always slips through. The solution: write automatic tests, small pieces of code that call your code and compare the result with the expected answer. Like a fish scale calibrated every morning: set up once, it checks in a blink.

Tests live in their own folder, call the project code, and pytest reports passed or failed.

assert: “make sure this is true”

The basis of every test in Python is assert. If the condition is true, the program carries on silently. If false, an AssertionError appears (an error meaning “the statement was not satisfied”).

01_assert_basics.py
# Chapter 18 - assert: "make sure this is true, otherwise stop"
biomass_kg = 800 * 540.5 / 1000

assert biomass_kg > 0
print("first assert passed: biomass", biomass_kg, "kg")

try:
    assert biomass_kg == 400, "biomass should be 400 kg"
except AssertionError as e:
    print("second assert failed:", e)

count = 0
try:
    assert count > 0
except AssertionError:
    print("an assert without a message can fail too")
first assert passed: biomass 432.4 kg
second assert failed: biomass should be 400 kg
an assert without a message can fail too

The first statement passes without a sound apart from the print after it. In the second, the comma adds a message that appears on failure. The script catches it with try/except (Chapter 12) so the program does not stop.

pytest and the first test

pytest is the most widely used test library among Python programmers. It is already installed in this book’s environment; on your computer, install it with pip install pytest inside a venv (Chapter 17). The rules are simple:

  • Test files are named test_*.py (or *_test.py).
  • Test functions are named test_....
  • Inside them, write assert.

(Source: the official pytest documentation, page “Good Integration Practices”, read 2026-10-10.)

02_first_test.py
# Chapter 18 - The first pytest test: a test_*.py file holding test_* functions
from pathlib import Path

from helpers import FOLDER, run_pytest

print(Path(FOLDER / "simple_example" / "test_calc.py").read_text(encoding="utf-8"))
print("--- pytest result ---")
text, code = run_pytest(["-v"], FOLDER / "simple_example")
print(text)
print("exit code:", code)
def biomass_kg(count, weight_g):
    return count * weight_g / 1000


def test_simple_biomass():
    assert biomass_kg(800, 500) == 400


def test_zero_biomass():
    assert biomass_kg(0, 540.5) == 0

--- pytest result ---
============================= test session starts =============================
collecting ... collected 2 items

test_calc.py::test_simple_biomass PASSED                                 [ 50%]
test_calc.py::test_zero_biomass PASSED                                   [100%]

============================== 2 passed ==============================
exit code: 0

This script runs pytest through subprocess on the simple_example/ folder. In the output, test_calc.py holds two test functions, both PASSED, and the summary says “2 passed”. Exit code 0 means everything is fine. pytest gives exit code 1 when any test fails, so automatic scripts and CI (Chapter 20) can use it. In this book’s output, the run time in the summary is deliberately removed so the result stays the same every time the book is built.

When a test fails

A test that always passes proves nothing yet; you need to know what the report looks like when it fails. The file failing_example/test_failing.py deliberately holds a wrong expectation (400 kg, while the calculation gives 432.4 kg).

04_failing_test.py
# Chapter 18 - Reading the report of a failed test
from helpers import FOLDER, run_pytest

text, code = run_pytest(["-q"], FOLDER / "failing_example")
print(text)
print("exit code:", code)
F                                                                        [100%]
================================== FAILURES ===================================
_______________________ test_biomass_wrong_expectation ________________________

    def test_biomass_wrong_expectation():
        fish = Fish("catfish", 800, 540.5, 7.2)
>       assert fish.biomass_kg() == 400.0
E       AssertionError: assert 432.4 == 400.0
E        +  where 432.4 = biomass_kg()
E        +    where biomass_kg = Fish(species='catfish', count=800, avg_weight_g=540.5, water_ph=7.2).biomass_kg

test_failing.py:6: AssertionError
=========================== short test summary info ===========================
FAILED test_failing.py::test_biomass_wrong_expectation - AssertionError: asse...
1 failed
exit code: 1

How to read it from the top:

  • The letter F marks one failed test (a passed test is a dot).
  • The arrow > points to the assert line that failed.
  • The E (error) lines explain: the actual value is 432.4, the expectation 400.0. pytest even breaks down where 432.4 came from (biomass_kg() on that Fish object).
  • Last come the FAILED ... summary and exit code 1.
TipTIP

When a test fails, do not change the test straight away to make it pass. Ask first: is the code wrong, or the expectation? In this example the expectation is wrong. Sometimes the code is wrong instead, and that is what tests are for.

Laying out the test folder

The inventory package from Chapter 16 now lives in the project/ folder. Tests go in a tests/ folder next to the package, not mixed in. pytest recommends this separation (official documentation, 2026-10-10).

project/
  pyproject.toml
  inventory/
    __init__.py   unit.py   items.py   sample.py   report.py
  tests/
    conftest.py
    test_items.py   test_unit.py   test_report.py

The pytest rules are written in pyproject.toml (Chapter 17):

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

testpaths tells pytest to look for tests only in tests/. pythonpath = ["."] adds the project folder to the import path, so import inventory works without installing it first.

03_list_tests.py
# Chapter 18 - The tests pytest finds in the project (without running them)
from helpers import FOLDER, run_pytest

text, code = run_pytest(["--collect-only", "-q"], FOLDER / "project")
print(text)
tests/test_items.py::test_fish_biomass
tests/test_items.py::test_tree_category[28.0-small]
tests/test_items.py::test_tree_category[32.5-medium]
tests/test_items.py::test_tree_category[45.2-large]
tests/test_items.py::test_tree_category[40.0-large]
tests/test_items.py::test_negative_fish_count_rejected
tests/test_report.py::test_save_summary
tests/test_unit.py::test_zero_area_rejected
tests/test_unit.py::test_parent_cannot_compute
tests/test_unit.py::test_pond_output
tests/test_unit.py::test_pond_summary
tests/test_unit.py::test_summary_five_units
tests/test_unit.py::test_items_cannot_be_changed_from_outside

13 tests collected

This is --collect-only mode: pytest only collects the tests without running them. From the three files, 13 tests were found. The test test_tree_category appears four times, tagged [28.0-small] and so on. The next section explains why.

Fixtures, parametrize, and raises

Three of pytest’s most-used tools, all present in our project.

A fixture is a “ready-made ingredient”: a function marked @pytest.fixture that is made fresh and lent to tests by name. Put it in conftest.py so every test file can use it.

conftest.py
"""Shared fixtures: ready-made ingredients for every test file."""

import pytest

from inventory import Fish, Pond
from inventory.sample import make_sample


@pytest.fixture
def sample_units():
    """Five sample units, freshly made for every test."""
    return make_sample()


@pytest.fixture
def pond_a():
    pond = Pond("A", 0.04)
    pond.add(Fish("catfish", 800, 540.5, 7.2))
    return pond

A test simply writes the fixture’s name as a parameter, and pytest fills it in. Every test receives a new object, so tests do not disturb each other.

test_items.py
import pytest

from inventory import Fish, Tree


def test_fish_biomass():
    fish = Fish("catfish", 800, 540.5, 7.2)
    assert fish.biomass_kg() == pytest.approx(432.4)


@pytest.mark.parametrize(
    ("diameter", "expected"),
    [(28.0, "small"), (32.5, "medium"), (45.2, "large"), (40.0, "large")],
)
def test_tree_category(diameter, expected):
    assert Tree("Teak", diameter, 15.0).category() == expected


def test_negative_fish_count_rejected():
    with pytest.raises(ValueError, match="negative"):
        Fish("catfish", -1, 500.0, 7.0)
  • @pytest.mark.parametrize runs one test function for several pairs of data. Four diameter pairs become four tests (that is the [28.0-small] above). The diameter 40.0 is included on purpose to test the boundary: the large category starts exactly at 40.
  • pytest.approx(432.4) compares decimal numbers with a small tolerance, so rounding noise does not fail the test (remember Chapter 6: decimals can be slightly off).
  • pytest.raises(ValueError, match="negative") states: “this code must raise an error”. If it does not, the test fails. This is how to test that the data guard works.
test_unit.py
import pytest

from inventory import Pond, Unit


def test_zero_area_rejected():
    with pytest.raises(ValueError):
        Pond("X", 0)


def test_parent_cannot_compute():
    with pytest.raises(NotImplementedError):
        Unit("Plain", 1.0).output()


def test_pond_output(pond_a):
    assert pond_a.output() == pytest.approx(432.4)


def test_pond_summary(pond_a):
    assert pond_a.summary() == "Pond A: 1 record(s), 432.4 kg biomass"


def test_summary_five_units(sample_units):
    text = [unit.summary() for unit in sample_units]
    assert text[0] == "Forest plot A: 3 record(s), 2.0 healthy trees"
    assert text[2] == "Rice field Lowland: 1 record(s), 1250.0 kg (2500 kg/ha)"
    assert len(text) == 5


def test_items_cannot_be_changed_from_outside(pond_a):
    assert isinstance(pond_a.items, tuple)
    with pytest.raises(AttributeError):
        pond_a.items.append("x")

The test test_summary_five_units uses the sample_units fixture and compares the summary text exactly. There is also test_parent_cannot_compute for the polymorphism behaviour of Chapter 16.

test_report.py
from inventory.report import save_summary


def test_save_summary(tmp_path, sample_units):
    path = tmp_path / "summary.txt"
    count = save_summary(sample_units, path)
    lines = path.read_text(encoding="utf-8").splitlines()
    assert count == 5
    assert lines[3] == "Pond A: 1 record(s), 432.4 kg biomass"

The built-in fixture tmp_path gives a temporary folder that is thrown away after the test, so tests that write files do not litter your computer.

NoteREMEMBER

A good test is small (one thing per test), independent (does not rely on other tests), and named for its expectation. test_zero_area_rejected tells you at once what is broken if it fails.

Running some of the tests

In a big project you often want only the tests related to the part you are changing. The -k option selects tests by a piece of the name, and -v (verbose) shows each test.

05_select_tests.py
# Chapter 18 - Running only some tests: -k selects by name
from helpers import FOLDER, run_pytest

text, code = run_pytest(["-v", "-k", "pond"], FOLDER / "project")
print(text)
============================= test session starts =============================
testpaths: tests
collecting ... collected 13 items / 11 deselected / 2 selected

tests/test_unit.py::test_pond_output PASSED                              [ 50%]
tests/test_unit.py::test_pond_summary PASSED                             [100%]

====================== 2 passed, 11 deselected =======================

Of the 13 tests collected, 11 were set aside (deselected) and only the 2 whose names contain “pond” ran.

The layout pytest suggests for a project that will be installed is the src/ layout: the package in src/inventory/ and tests in tests/, then installed with pip install -e . (“editable”: code changes are read at once without reinstalling). This book uses a flat layout (the package directly in project/) with pythonpath = ["."] because it is simpler for beginners. The pytest documentation also recommends --import-mode=importlib for new projects; that option is not used here.

Running Project I1: tests for the inventory package

Now all project tests run at once. Look back at sample_units from Chapter 16: those five units are now guarded by tests.

06_project_all_tests.py
# Chapter 18 - Running project I1: all tests of the inventory package
from helpers import FOLDER, run_pytest

text, code = run_pytest(["-v"], FOLDER / "project")
print(text)
print("exit code:", code)
============================= test session starts =============================
testpaths: tests
collecting ... collected 13 items

tests/test_items.py::test_fish_biomass PASSED                            [  7%]
tests/test_items.py::test_tree_category[28.0-small] PASSED               [ 15%]
tests/test_items.py::test_tree_category[32.5-medium] PASSED              [ 23%]
tests/test_items.py::test_tree_category[45.2-large] PASSED               [ 30%]
tests/test_items.py::test_tree_category[40.0-large] PASSED               [ 38%]
tests/test_items.py::test_negative_fish_count_rejected PASSED            [ 46%]
tests/test_report.py::test_save_summary PASSED                           [ 53%]
tests/test_unit.py::test_zero_area_rejected PASSED                       [ 61%]
tests/test_unit.py::test_parent_cannot_compute PASSED                    [ 69%]
tests/test_unit.py::test_pond_output PASSED                              [ 76%]
tests/test_unit.py::test_pond_summary PASSED                             [ 84%]
tests/test_unit.py::test_summary_five_units PASSED                       [ 92%]
tests/test_unit.py::test_items_cannot_be_changed_from_outside PASSED     [100%]

============================= 13 passed ==============================
exit code: 0

All 13 tests pass. From now on, whenever you change the Unit class or its children, run python -m pytest. If any number changes by accident, pytest tells you exactly where. That is what “safe to change” means in the closing of the series.

Exercises

  1. In simple_example/test_calc.py, add a third test: biomass_kg(1250, 480) must be 600. Run it with python -m pytest in that folder.
  2. Change the expected number in failing_example/test_failing.py to pytest.approx(432.4) and run it; make sure the test passes.
  3. Write a test test_pen_output for the Pen class: add three Livestock records (350, 412.5, and 389.0 kg) and check the output is 1151.5.
  4. Challenge: write a parametrized test for Tree.category() with five diameters, including the boundary 30.0. Then deliberately change >= 30 to > 30 in items.py, run the tests, see the boundary test fail, and restore the code.

Chapter Summary

  • Automatic tests check your code every time they run, so changes do not break things silently.
  • assert is the basis; files test_*.py holding functions test_* are found automatically by pytest.
  • A failed test shows F; the > line points at the assert, the E lines give the actual value and the expectation. Exit code 1.
  • Fixtures (conftest.py), parametrize, pytest.approx, pytest.raises, and tmp_path are everyday tools.
  • Put tests in a tests/ folder next to the package; configure under [tool.pytest.ini_options] in pyproject.toml.
  • The inventory package now has 13 passing tests.

Next, in Chapter 19: code quality, how to tidy your writing style automatically with ruff, record a program’s progress with logging, and document the project.