pytest Explained: Fixtures, Parametrize and Plain Asserts
pytest runs plain test functions and shows the values in a failed assert. Fixtures injected by name, yield teardown, parametrize, raises, approx and flags.
- Course: Python study plan
- Module: Testing and debugging
- Kind: Lesson
- Reading time: 13 min
- Runtime: CPython 3.11
What is pytest in Python?
pytest is the third-party test runner most Python projects use. Tests are plain functions named test_* that use the ordinary assert statement, which pytest rewrites to show every sub-expression's value on failure. Fixtures are functions injected into tests by parameter name, @pytest.mark.parametrize turns one test into a table of cases, and pytest also runs existing unittest suites unchanged.
Lesson
pytest is the test runner most Python projects use: tests are plain functions named test_*, assertions are the assert statement with the failing expression's values shown on failure, fixtures are functions whose return value is injected by parameter name, and @pytest.mark.parametrize turns one test into a table. It is a third-party package (pip install pytest) and the judge does not have it, so this lesson is reading and the exercises rebuild its two central mechanisms — parametrisation and fixtures with teardown — in plain Python, which is the best way to understand what pytest does for you. It covers the assert rewriting, fixtures and their scopes, parametrize, raises, markers and the command-line flags you use daily, and how pytest runs unittest suites unchanged.
A test file
# test_slugify.py
from slugify import slugify
def test_lowercases_and_joins():
assert slugify("Hello World") == "hello-world"
def test_collapses_whitespace():
assert slugify(" a b ") == "a-b"
pytest in the project directory finds test_*.py and *_test.py files, collects test_* functions and Test* classes (no base class needed), runs them, and prints a dot per pass. On failure it shows the assertion with every sub-expression's value:
def test_collapses_whitespace():
> assert slugify(" a b ") == "a-b"
E AssertionError: assert 'a--b' == 'a-b'
E - a-b
E + a--b
That is assertion rewriting: pytest recompiles test modules so a plain assert reports like a specialised method. There is no assertion family to learn.
Fixtures
import pytest
@pytest.fixture
def inventory():
inv = Inventory()
inv.add("bolt", 10)
yield inv # the test runs here
inv.close() # teardown, even if the test failed
def test_remove(inventory): # injected by parameter name
inventory.remove("bolt", 4)
assert inventory.count("bolt") == 6
A fixture is a function decorated with @pytest.fixture; a test that names it as a parameter receives its value. A fixture that yields runs its remainder as teardown. Fixtures can use other fixtures, and scope="module" or "session" makes one instance serve many tests (a database connection). Fixtures in conftest.py are available to every test in that directory tree without imports. Built-in fixtures: tmp_path (a fresh temporary directory as a Path), monkeypatch (monkeypatch.setattr(obj, "name", value), setenv, all undone afterwards), capsys (captured stdout/stderr via capsys.readouterr()), caplog (captured log records).
parametrize
@pytest.mark.parametrize("text, expected", [
("1h", 3600),
("30m", 1800),
("1h30m", 5400),
pytest.param("", 0, id="empty"),
])
def test_parse_duration(text, expected):
assert parse_duration(text) == expected
One function, four tests, each reported separately as test_parse_duration[1h-3600], and each failing independently. Stacking two parametrize decorators produces the cross product. This is subTest without the indentation and with proper per-case identity.
raises, approx, markers
with pytest.raises(ValueError, match="name"):
User(name="")
assert total == pytest.approx(99.0) # float tolerance
@pytest.mark.skipif(sys.platform == "win32", reason="posix only")
@pytest.mark.xfail(reason="bug #42")
@pytest.mark.slow # custom marker; select with -m slow
pytest.raises is the assertRaises twin, match is a regex on the message; approx compares floats and sequences of floats with relative tolerance; markers tag tests for selection, skipping and expected failure.
The command line
| Flag | Effect |
|---|---|
pytest -x | stop at the first failure |
pytest -k "slug and not empty" | select by name expression |
pytest path/test_x.py::test_name | one test |
pytest --lf / --ff | rerun last failures / failures first |
pytest -v / -q | verbose / quiet |
pytest -s | do not capture stdout (see prints) |
pytest --pdb | drop into the debugger on failure |
pytest -m slow / -m "not slow" | by marker |
pytest --durations=10 | the slowest tests |
Configuration lives in pyproject.toml under [tool.pytest.ini_options] (testpaths, addopts, registered markers). Plugins add flags: pytest-cov for coverage, pytest-xdist for -n auto parallel runs, pytest-asyncio for async def tests.
unittest under pytest
pytest runs unittest.TestCase classes as they are — setUp, assertion methods and all — so a code base can migrate one file at a time, and a new test file beside old ones can use the plain style. The two styles differ in fixtures (methods versus injected functions) and assertions (methods versus assert); the discipline of the previous lesson — one behaviour per test, edges and errors, fresh state — is identical.
Rebuilding the mechanisms
Both of pytest's central ideas are small. Parametrisation is a decorator that attaches a list of argument tuples to a function, and a runner that calls the function once per tuple, catching AssertionError and reporting each case by name. A yield-fixture is a generator: the runner calls next() to get the value, runs the test, then calls next() again inside a finally so teardown runs whether the test passed or not. The exercises build exactly these, which is also what you would write to run a table of checks in a script with no framework available.
Pitfalls
- A fixture that does setup with
returnand expects teardown to run — onlyyieldfixtures tear down. assertin production code for validation —python -Ostrips it; use exceptions.- A parametrize id that is unreadable in the report —
pytest.param(..., id="…"). - Tests that pass only because of a session-scoped fixture's leftover state.
- Forgetting
-sand wondering where the prints went. - Mixing
unittestassertion methods into plain functions (selfdoes not exist there).
Key takeaways
- pytest collects
test_*functions and reports plainassertwith values; no base class, no assertion family. - Fixtures are injected by parameter name;
yieldfixtures tear down;tmp_path,monkeypatch,capsysare built in;conftest.pyshares them. @pytest.mark.parametrizemakes a table into independent tests;raises,approxand markers cover exceptions, floats and selection.-x,-k,--lf,-s,--pdbare the daily flags; configuration lives inpyproject.toml.- Parametrisation is "call once per tuple"; a yield fixture is a generator driven by
next()around the test — small enough to rebuild when pytest is not there.
Common questions
What is a pytest fixture?
A function decorated with @pytest.fixture whose value is passed to any test that names it as a parameter. A fixture that yields runs the code after the yield as teardown, even when the test fails; scope='module' or 'session' shares one instance across tests, and fixtures in conftest.py are shared across a directory.
How does pytest.mark.parametrize work?
It attaches a list of argument tuples to a test, and pytest runs the function once per tuple, reporting each case separately, as in test_parse_duration[1h-3600], so one failing case does not hide the others. pytest.param(..., id='empty') gives a case a readable name.
What is the difference between pytest and unittest?
unittest is in the standard library: tests are methods on a TestCase class with assertion methods and setUp. pytest is a third-party package with plain functions, bare assert and fixtures injected by name, and it runs unittest tests too, so a project can migrate one file at a time.
How do you test for an exception in pytest?
Put the call inside with pytest.raises(ValueError, match='name'):, where match is a regular expression checked against the message. For floats, compare with pytest.approx rather than ==.
Why can't I see print output in pytest?
pytest captures stdout and stderr by default and shows them only for failing tests. Run pytest -s to turn capture off, or use the capsys fixture and capsys.readouterr() to check the output inside a test.
Exercises
parametrize in plain Python
Read rows a b expected until EOF. Write a decorator cases(*rows) that stores the rows on the function as fn.cases, and a runner run(*tests) that, for each test function and each of its rows in order, calls fn(*row); on success it prints <name>[<i>] PASS, on AssertionError it prints <name>[<i>] FAIL <message>. Define add(a, b), then test_add(a, b, expected) asserting add(a, b) == expected with the message got <result>, and test_commutes(a, b, expected) asserting add(a, b) == add(b, a); decorate both with the rows and run them. Finish with <p> passed, <f> failed.
Input: one row per line. Output: one line per test and row, then the summary.
1 2 3
2 2 5
0 0 0
prints
test_add[0] PASS
test_add[1] FAIL got 4
test_add[2] PASS
test_commutes[0] PASS
test_commutes[1] PASS
test_commutes[2] PASS
5 passed, 1 failedA yield fixture
Write a generator fixture connection() that prints setup, yields the dict {"open": True}, then sets "open" to False and prints teardown. Write run_with(fixture, test) that drives the generator with next, calls test(value), prints <test name> PASS or <test name> FAIL <message> on AssertionError, and always runs the teardown in a finally. Read lines ok or fail until EOF and build one test per line named test_<i> (use a factory so each has its own name via __name__) that asserts conn["open"] and, for fail, then assert False, "forced failure". Run them in order and finish with <p> passed, <f> failed.
Input: one marker per line. Output: setup/result/teardown per test, then the summary.
ok
fail
prints
setup
test_0 PASS
teardown
setup
test_1 FAIL forced failure
teardown
1 passed, 1 failedIn this module: Testing and debugging
- The testing mindset — what to test, how to arrange it, and designing for testability
- unittest — TestCase, assertions, fixtures, subTest and running suites
- pytest in outline — plain asserts, fixtures, parametrize and the command line (this lesson)
- Mocking and test doubles — Mock, patch, side_effect and where to patch
- Debugging — reading tracebacks, logging, pdb and the method
- Checkpoint — Testing and debugging
← unittest — TestCase, assertions, fixtures, subTest and running suites · Mocking and test doubles — Mock, patch, side_effect and where to patch →