Pytest Fixtures and Parametrization: Building Reusable Test Setups
Combine pytest fixtures with indirect parametrization to eliminate test setup duplication while keeping test functions readable. A worked example shows how to pass scenario names into a fixture that returns tailored test data.
14 Jul 2026, 07:26 UTC

The Duplication Problem
You're testing a user authentication module. Three test cases need a database connection: valid credentials, expired token, and locked account. Each test opens a connection, seeds specific data, runs assertions, then closes the connection. The setup code repeats across tests, and when the schema changes, you update it in five places.
This is where pytest fixtures and parametrization intersect. Fixtures provide dependency injection—functions decorated with @pytest.fixture return values that test functions receive as arguments by name. Parametrization via @pytest.mark.parametrize generates multiple test invocations from a single function. Combined, they let you define setup once and vary inputs cleanly.
Fixtures as Dependency Injection
A fixture encapsulates setup and teardown. The yield keyword separates them: code before runs at setup, code after runs at teardown even if the test fails. Scopes control lifecycle—function (default), class, module, package, session. A session-scoped fixture runs once per test run, ideal for expensive resources like a database engine or browser driver.
# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("sqlite:///:memory:")
# create tables
yield engine
engine.dispose()
@pytest.fixture
def db_session(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
The db_session fixture depends on db_engine—pytest resolves the dependency graph automatically. Each test gets a fresh session with a rolled-back transaction, ensuring isolation without manual cleanup.
Indirect Parametrization: Passing Parameters into Fixtures
Standard parametrization passes values directly to test arguments. Indirect parametrization passes them into the fixture itself via request.param. This keeps test signatures clean while letting the fixture produce different return values per test case.
# test_auth.py
import pytest
from auth import authenticate
@pytest.fixture
def user_data(request):
"""Return user record matching the parametrized scenario."""
scenarios = {
"valid": {"email": "[contact removed]", "password_hash": hash_pw("secret"), "locked": False},
"expired_token": {"email": "[contact removed]", "password_hash": hash_pw("secret"), "locked": False, "token_expiry": "2020-01-01"},
"locked": {"email": "[contact removed]", "password_hash": hash_pw("secret"), "locked": True},
}
return scenarios[request.param]
@pytest.mark.parametrize("user_data,expected", [
("valid", True),
("expired_token", False),
("locked", False),
], indirect=["user_data"])
def test_authenticate(db_session, user_data, expected):
# seed the parametrized user
db_session.add(User(**user_data))
db_session.commit()
result = authenticate(db_session, user_data["email"], "secret")
assert result is expected
The test function declares user_data as a parameter. The indirect=["user_data"] marker tells pytest to pass each string ("valid", "expired_token", "locked") to the fixture's request.param instead of the test. The fixture returns the full user dictionary. The test stays focused on the authentication logic.
Alternative: Fixture-Level Parametrization
Since pytest 6.0, @pytest.fixture(params=[...]) lets the fixture itself iterate over parameter values. Each parameter value produces a separate test invocation automatically.
@pytest.fixture(params=["chrome", "firefox", "safari"])
def browser(request):
driver = create_driver(request.param)
yield driver
driver.quit()
def test_homepage_loads(browser):
browser.get("https://example.com")
assert "Example" in browser.title
This runs test_homepage_loads three times—once per browser. The fixture handles driver creation and cleanup per invocation. Use this when the parameter naturally belongs to the fixture (browser type, database backend) rather than the test case (user scenario, input values).
Trade-offs and Limitations
- Indirection obscures data flow. Readers unfamiliar with
indirect=Truemay not realize the fixture receives parameters viarequest.param. Document the fixture's expected parameter values in its docstring. - Combinatorial explosion. Parametrizing multiple fixtures multiplies test count. A fixture with 3 params × another with 4 params = 12 test runs. Use
pytest -kto filter, or filter inside the fixture withif request.param not in allowed: pytest.skip(). - Session-scoped mutable state leaks. If a session fixture returns a mutable object (a list, a counter), tests can modify it and affect later tests. Prefer immutable returns or explicit cleanup in finalizers.
- Fixture name collisions. Redefining a fixture in a subdirectory's
conftest.pyoverrides the parent definition for tests in that subtree. This enables test-specific behavior but can surprise newcomers. Runpytest --fixturesto see resolved fixtures and their scopes. - Teardown after yield doesn't run if setup fails before yield. Wrap risky setup in
try/finallyinside the fixture if you need guaranteed cleanup.
When to Reach for This Pattern
Use indirect parametrization when:
- Multiple tests need the same fixture but with different configurations (different user records, different API mock responses, different database seeds).
- The fixture setup is complex enough to warrant reuse, but the variation is simple enough to express as data.
- You want test functions to read like specifications—
test_authenticate(valid_user)—not setup scripts.
Start by extracting repeated setup into a fixture. Then identify the varying inputs. If they map cleanly to fixture parameters, add @pytest.mark.parametrize with indirect=True. Run pytest -v --setup-show to verify fixture execution order and scope behavior before committing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.