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.
pytest [-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
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.
# 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.
# 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_retriedReading 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.
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