pytest Fixtures and parametrize
Introduction¶
pytest's two big ideas are fixtures (dependency-injected setup/teardown) and parametrization (one test function, many inputs). Used well they remove most test boilerplate.
Fixtures¶
import pytest
@pytest.fixture
def db_connection():
conn = connect(":memory:")
conn.execute(SCHEMA)
yield conn # test runs here
conn.close() # teardown after the test
def test_insert(db_connection):
db_connection.execute("INSERT INTO users VALUES (1, 'alice')")
assert db_connection.execute("SELECT count(*) FROM users").fetchone()[0] == 1
The test just names db_connection as a parameter; pytest builds it, injects
it, and tears it down. yield splits setup from teardown (teardown runs even if
the test fails).
Scope¶
@pytest.fixture(scope="session") # once per test run
@pytest.fixture(scope="module") # once per test file
@pytest.fixture(scope="class")
@pytest.fixture(scope="function") # default — once per test
A session-scoped Docker container or database; a function-scoped clean
transaction inside it:
@pytest.fixture(scope="session")
def pg_engine():
with PostgresContainer("postgres:16") as pg:
yield create_engine(pg.get_connection_url())
@pytest.fixture
def db(pg_engine):
conn = pg_engine.connect()
txn = conn.begin()
yield conn
txn.rollback() # every test starts clean, fast
conn.close()
conftest.py¶
Fixtures in conftest.py are available to every test in that directory and
below, with no import. Put shared fixtures there; nest conftest.py files for
directory-scoped ones. Also where you put pytest_* hooks and shared markers.
Composition and autouse¶
@pytest.fixture
def user(db):
return db.create_user("alice")
@pytest.fixture
def authed_client(client, user):
client.login(user)
return client
@pytest.fixture(autouse=True)
def _reset_cache():
cache.clear()
yield
cache.clear()
Fixtures depend on other fixtures freely. autouse=True runs it for every test
in scope without being named — use sparingly (implicit setup is hard to trace).
parametrize¶
@pytest.mark.parametrize("value, expected", [
("192.168.1.1", True),
("256.1.1.1", False),
("", False),
("::1", True),
])
def test_is_valid_ip(value, expected):
assert is_valid_ip(value) is expected
One test, four cases, four line items in the report. Add ids= for readable
names, or use pytest.param(..., id="...", marks=pytest.mark.xfail) per case.
Stack decorators for a matrix:
@pytest.mark.parametrize("scheme", ["http", "https"])
@pytest.mark.parametrize("port", [80, 8080, 443])
def test_url(scheme, port): # runs 6 combinations
...
Parametrizing fixtures¶
@pytest.fixture(params=["sqlite", "postgres"])
def db_url(request):
return DB_URLS[request.param]
# every test using db_url now runs twice, once per backend
Indirect parametrization passes the param through a fixture:
@pytest.fixture
def user(request, db):
return db.create_user(role=request.param)
@pytest.mark.parametrize("user", ["admin", "viewer"], indirect=True)
def test_permissions(user):
...
Markers¶
@pytest.mark.slow
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
@pytest.mark.xfail(reason="known bug #123", strict=True)
# pyproject.toml
[tool.pytest.ini_options]
markers = ["slow: long-running tests"]
addopts = "-ra --strict-markers"
pytest -m "not slow" to skip them; --strict-markers errors on typos.
Verification and troubleshooting¶
pytest --fixtures # list available fixtures + docstrings
pytest --setup-show test_x.py # see fixture setup/teardown order
pytest -k "insert and not slow"
pytest --lf # last failed
pytest -x --pdb # stop on first failure, drop into debugger
fixture 'x' not found— not in scope (wrongconftest.pylevel), typo, or the file defining it isn't collected.pytest --fixturesshows what's visible.- Fixture runs more times than expected — scope is
function(default) when you wantedsession/module; or a parametrized fixture upstream multiplies it. - Teardown not running — you used
returninstead ofyield, or the setup raised beforeyield(then teardown is skipped — guard with try/finally or keep setup minimal). - Tests pass alone, fail together — shared mutable state (a module global, a
real DB row, a cache) not reset between tests. Add an
autousereset fixture or a per-test transaction rollback. parametrizecase order matters / flaky — tests share state; each parametrized run should be independent.- Slow suite —
--durations=10to find the worst; move heavy setup tosessionscope, mock external calls, mark and split slow tests.
Related tools and reading¶
- On-site: Regex Tester.
- Related posts: Profiling Python code, Type hints that actually pay off.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Python
asyncio for I/O-Bound Scripts
Async Python shines for hundreds of concurrent network calls and nothing else. Here's a correct pattern for a bulk-fetch script, with concurrency limits and error handling.
October 24, 2026Python Type Hints That Actually Pay Off
Type hints earn their keep at function boundaries and data models. Here's a pragmatic subset, a CI setup, and how to adopt them on an untyped codebase.
October 24, 2026Packaging a Python CLI
Turn a script into a `pip install`-able command: the pyproject.toml, the entry point, the src layout, and how users should install it.
October 24, 2026