PYTHON / TESTING AND TOOLING
pytest, fixtures, and parametrize
Write pytest tests that share setup through fixtures and cover many inputs with @pytest.mark.parametrize, then read the run report.
What you will learn
- Request a fixture by naming it as a test parameter; pytest calls it and injects the result
- Use yield in a fixture so cleanup runs even when the test body raises
- parametrize creates one independent test item per case, each with its own id and report line
- Default function scope rebuilds the fixture per test; wider scopes share mutable state
Understanding pytest, fixtures, and parametrize
pytest finds tests by convention: files named test_*.py, functions named test_*, and plain assert statements rather than assertEqual-style methods. Plain assert is enough because pytest rewrites assert statements when it imports your test module, so a failure can print both operands instead of just 'AssertionError'. Anything a test needs beyond that — a dict of fixed data, a temp directory, a fake connection — is declared as a parameter name in the test signature, and at setup time pytest resolves each name to a fixture function, calls it, and passes the return value in.
A fixture is a function decorated with @pytest.fixture. If it returns, that value is injected; if it yields, pytest injects the yielded value and runs the code after the yield as teardown once the test finishes, including when the test failed. The scope argument decides how often the function runs: the default 'function' scope rebuilds the value for every test, which is what keeps tests independent, while scope="module" or "session" builds it once and shares the same object. Fixtures may request other fixtures through the same parameter-name mechanism, so setup composes into a small dependency graph instead of one long setUp method.
@pytest.mark.parametrize does not loop inside a test. At collection time it expands one function into one test item per case, each with its own id, its own fixture setup, and its own line in the report. That is the practical difference from a for loop over inputs: with parametrize, three bad inputs surface as three failures whose ids name the offending values, while a loop raises on the first bad input and never reaches the rest. The argnames string is matched against the function's parameters exactly like fixture names are, and when a parametrized name collides with a fixture name, the parametrized value wins.
# test_pricing.py -- run with: pytest -q (or: python test_pricing.py)
import pytest
def apply_discount(total, code, codes):
if code not in codes:
raise KeyError(code)
return round(total * (1 - codes[code]), 2)
pytest.fixture
def codes():
return {"SAVE10": 0.10, "HALF": 0.50}
pytest.mark.parametrize(
"total, code, expected",
[
(100.0, "SAVE10", 90.0),
(100.0, "HALF", 50.0),
(19.99, "SAVE10", 17.99),
],
)
def test_apply_discount(codes, total, code, expected):
assert apply_discount(total, code, codes) == expected
def test_unknown_code_raises(codes):
with pytest.raises(KeyError):
apply_discount(100.0, "NOPE", codes)
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
Fixtures and parametrize are two halves of one mechanism: pytest fills every test parameter by name, either from a setup function's result or from a single case's value.
Worked examples
yield fixtures: setup, teardown, and isolation
Shows when the code before and after yield runs, and that each test gets a brand new value.
# test_resource.py
import pytest
events = []
pytest.fixture
def rows():
events.append("setup")
data = []
yield data
events.append("teardown")
def test_first_use(rows):
rows.append("a")
assert rows == ["a"]
assert events == ["setup"]
def test_teardown_already_ran():
assert events == ["setup", "teardown"]
def test_fresh_list_each_time(rows):
assert rows == []
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
Example explained
Line 1events.append("setup") runs during setup, before the test body; the object after yield is what the test receives.
Line 2test_teardown_already_ran does not request rows, so no new setup happens and it can see that teardown already ran for the previous test.
Line 3test_fresh_list_each_time receives an empty list because function scope calls the fixture again; the "a" from the first test is gone.
Line 4Everything after yield still runs if the test body raises, which is why cleanup belongs there and not at the end of the test.
Readable case ids and a known-bad case
Names each parametrized case with pytest.param and marks one case as an expected failure.
# test_parse.py
import pytest
def parse_port(text):
port = int(text)
if not 0 < port < 65536:
raise ValueError(f"port out of range: {port}")
return port
pytest.mark.parametrize(
"text, expected",
[
pytest.param("80", 80, id="http"),
pytest.param("65535", 65535, id="max"),
pytest.param(" 8080 ", 8080, id="padded"),
pytest.param(
"0x50",
80,
id="hex",
marks=pytest.mark.xfail(reason="int() needs base=16", raises=ValueError),
),
],
)
def test_parse_port(text, expected):
assert parse_port(text) == expected
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
Example explained
Line 1pytest.param(..., id="http") replaces the auto-generated id, so a failure reads test_parse_port[http] instead of test_parse_port[80-80].
Line 2The " 8080 " case passes because int() strips surrounding whitespace; the case documents that behaviour instead of leaving it to guesswork.
Line 3marks=pytest.mark.xfail(raises=ValueError) applies only to that one case: it is reported as x, and the run still exits 0.
Line 4If int("0x50") ever started working, pytest would report XPASS, which tells you the mark is stale.
Building on built-in fixtures
A custom fixture that requests tmp_path, plus monkeypatch undoing an environment change automatically.
# test_config.py
import json
import os
import pytest
def load_settings(path):
settings = json.loads(path.read_text())
override = os.environ.get("APP_TIMEOUT")
if override is not None:
settings["timeout"] = int(override)
return settings
pytest.fixture
def config_file(tmp_path):
path = tmp_path / "settings.json"
path.write_text('{"host": "localhost", "timeout": 30}')
return path
def test_file_value_used(config_file, monkeypatch):
monkeypatch.delenv("APP_TIMEOUT", raising=False)
assert load_settings(config_file) == {"host": "localhost", "timeout": 30}
def test_env_overrides_file(config_file, monkeypatch):
monkeypatch.setenv("APP_TIMEOUT", "5")
assert load_settings(config_file)["timeout"] == 5
def test_env_restored(config_file):
assert "APP_TIMEOUT" not in os.environ
assert load_settings(config_file)["timeout"] == 30
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
Example explained
Line 1config_file requests tmp_path, a built-in fixture giving a fresh directory per test, which shows fixtures can depend on other fixtures.
Line 2Each test writes to its own tmp_path, so no test can see or clobber another test's settings.json.
Line 3monkeypatch.setenv is reverted during monkeypatch's own teardown, which is why test_env_restored sees a clean environment with no cleanup code.
Line 4Requesting two fixtures is just two parameters; the order in the signature has no effect on setup order.
Important notes
The value lists in parametrize are evaluated at collection time, so they cannot use fixture data; build anything fixture-dependent inside the test, or use indirect parametrization.
Fixtures defined in conftest.py next to your tests are available to every test file in that directory tree with no import; only the name matters.
Common mistakes
Calling the fixture like a normal function inside the test (codes() instead of taking codes as a parameter): pytest fails the test with "Fixture 'codes' called directly", because fixtures only run through injection.
Misspelling a name in the parametrize argnames string, or forgetting to add it to the function signature: pytest treats the unknown name as a fixture and errors with "fixture 'expected' not found", or complains the function uses no such argument.
Switching a fixture to scope="module" or "session" to speed things up while the tests still mutate the shared object: tests start passing or failing depending on the order they run in.
Try it yourself
Change, predict, then run
Create test_stats.py with a fixture sample that returns [3, 1, 4, 1, 5] and one parametrized test over the pairs (min, 1), (max, 5), (sum, 14) asserting func(sample) == expected. Run pytest -q and confirm three separate passing cases rather than one.
Open the Python workspaceCheck your understanding
A test loops over five input tuples and asserts inside the loop; two of those inputs are broken. You rewrite it as a parametrized test with the same five cases. How does the report change?
- Two of five tests fail, each named after its own case, and the other three still run and pass
- One test fails once, at the first broken input, and the remaining inputs are never checked
- The test count stays at one, but the single failure message now lists both broken inputs
- All five cases fail, because parametrize shares one fixture setup across the whole group
Show answer
parametrize expands the function into five independent test items collected separately, so each gets its own setup, run, and report line; two failures appear with the offending values in their ids. Option 2 describes the loop version you replaced: an assert raises on the first bad input and abandons the rest of the iterations.