Testing with pytest

Plain functions and a bare assert, names that read as failures, parametrize for the table of cases, and running one test while you work.

Syntaxpytest [-q] [-x] [-k EXPR] [--lf] [path/test_file.py::test_name]

pytest runs plain functions. A file called test_billing.py, a function called test_rejects_zero, and a bare assert is a complete test: no class to subclass, no setUp. pytest rewrites assert statements in the modules it collects, so a failure prints both sides of the comparison. That is why assert got == expected replaces self.assertEqual(got, expected) from unittest — the same check, a better report. pytest is not in the standard library; install it with python -m pip install pytest.

The name is the failure message

In CI you read names, not bodies. test_rejects_negative_amount tells you what broke; test_charge_2 sends you back to the source.

One row, one test

A table of cases belongs in @pytest.mark.parametrize(argnames, argvalues, indirect=False, ids=None, scope=None). It is shorthand for writing one test function per row: each row is collected as its own test with its own id, so a failing row does not hide the ones after it, which is exactly what a for loop inside one test does. pytest.param(value, marks=pytest.mark.xfail, id="overflow") labels or marks a single row.

Running a subset

A node id runs one test: pytest tests/test_billing.py::test_rejects_zero. -k "declined and not slow" selects by name expression, -x stops at the first failure, --lf reruns only what failed last time, -q trims the output. Expected failures are assertions too: with pytest.raises(ValueError, match="amount"):.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Pin down a bug with a failing test before you change the code that causes it
  • Cover a parser or formatter with a table of input/output pairs instead of a dozen near-identical functions
  • Rerun just the two tests you broke with --lf while you work, then the suite before you push
  • Assert that bad input raises your own exception type rather than returning a quietly wrong value

More examples

The same tests as pytest actually sees them

Four cases become four named tests: pytest reports test_slugify_table[A B-a-b] rather than one opaque failure, and the xfail row documents a known gap without turning the suite red. pytest.raises is how you assert on the failure path; match is a regular expression searched against the string form of the exception.

Example · python
# tests/test_slugify.py
import pytest

from reporting.slugs import slugify


def test_lowercases_and_joins_words():
    assert slugify("Hello World") == "hello-world"


@pytest.mark.parametrize(
    "title,expected",
    [
        ("A  B", "a-b"),
        ("v2.0 Release", "v2-0-release"),
        ("---", ""),
        pytest.param("ÄÖ", "ao", marks=pytest.mark.xfail(reason="no transliteration yet")),
    ],
)
def test_slugify_table(title, expected):
    assert slugify(title) == expected


def test_rejects_non_string():
    with pytest.raises(TypeError, match="str"):
        slugify(None)

Settings so the whole team runs it the same way

Put the options in the repo, not in everyone's shell history, so a local run and a CI run collect the same tests. --strict-markers turns a typo'd marker name into an error instead of a silently ignored decorator.

Example · toml
# pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = "-q --strict-markers"

# Then the bare command picks all of this up:
#   pytest                      -> quiet, tests/ only
#   pytest -k declined          -> just the declined cases
#   pytest --lf -x              -> last failures, stop on the first
#   pytest tests/test_api.py::test_timeout_is_retried

Reading a failure

This is what assert rewriting buys you: the values, the call that produced them, and the line. Nothing was logged or printed by the test to get it. If you find yourself adding print() calls to a failing test, the assertion is probably checking too much at once - split it.

Example · text
FAILED tests/test_billing.py::test_total_includes_tax - assert 1250 == 1275

    def test_total_includes_tax():
        line = {"cents": 1000, "tax_rate": 0.25}
>       assert total(line) == 1275
E       assert 1250 == 1275
E        +  where 1250 = total({'cents': 1000, 'tax_rate': 0.25})

Discussion

  • Be the first to comment on this lesson.