Server

pytest Fixtures and parametrize

By Prabath Thalangama· October 24, 2026· 3 min read
#python#pytest#testing

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 (wrong conftest.py level), typo, or the file defining it isn't collected. pytest --fixtures shows what's visible.
  • Fixture runs more times than expected — scope is function (default) when you wanted session/module; or a parametrized fixture upstream multiplies it.
  • Teardown not running — you used return instead of yield, or the setup raised before yield (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 autouse reset fixture or a per-test transaction rollback.
  • parametrize case order matters / flaky — tests share state; each parametrized run should be independent.
  • Slow suite — --durations=10 to find the worst; move heavy setup to session scope, mock external calls, mark and split slow tests.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.