pytest Fixtures and Parametrize
Fixtures and parametrize are the two features that move pytest from “basic tests” to “maintainable test suites.” Fixtures let you share setup code without copy-pasting it into every test. Parametrize runs the same test function against a table of inputs. Together they cut repetition and make tests easier to maintain.
Fixtures: The Basics
A fixture is a function that pytest calls to provide data to your tests:
import pytest
@pytest.fixture
def empty_db():
return {"users": [], "orders": []}
def test_empty_db_has_no_users(empty_db):
assert empty_db["users"] == []
def test_empty_db_has_no_orders(empty_db):
assert empty_db["orders"] == []
The empty_db fixture runs once per test that uses it. pytest passes the fixture’s return value as an argument to each test.
Fixtures That Need Teardown
If your fixture creates a resource that needs cleanup, use a yield fixture:
import tempfile
import os
@pytest.fixture
def temp_csv():
# Create a temp file
fd, path = tempfile.mkstemp(suffix=".csv")
os.close(fd)
yield path # provide the path to the test
# Cleanup after the test
os.unlink(path)
def test_read_csv(temp_csv):
with open(temp_csv, "w") as f:
f.write("name,revenue\nAlice,100\nBob,200\n")
import pandas as pd
df = pd.read_csv(temp_csv)
assert len(df) == 2
The code after yield runs after the test finishes, whether the test passes or fails.
Fixture Scope
By default, a fixture runs once per test. You can change that with scope:
@pytest.fixture(scope="module")
def database():
"""One database connection for an entire test module."""
conn = connect_to_db()
yield conn
conn.close()
@pytest.fixture(scope="session")
def app_config():
"""App config shared across all tests in the session."""
return {"env": "test", "debug": True}
scope="function"(default) — new fixture for each testscope="class"— one fixture per test classscope="module"— one fixture per module (file)scope="session"— one fixture for the entire test run
Session-scoped fixtures are useful for expensive operations you only want to do once: spinning up a container, loading a large dataset, configuring global state.
Fixtures That Accept Other Fixtures
Fixtures can depend on other fixtures:
@pytest.fixture
def db_connection():
return create_connection("test_db")
@pytest.fixture
def db_with_schema(db_connection):
run_schema(db_connection, """
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL);
""")
return db_connection
def test_insert_user(db_with_schema):
db_with_schema.execute("INSERT INTO users VALUES (1, 'Alice')")
result = db_with_schema.execute("SELECT * FROM users")
assert len(result) == 1
pytest resolves the dependency graph automatically. The order of fixture execution follows the dependency chain.
Parametrize: One Test, Many Inputs
Instead of writing multiple test functions that look the same, use @pytest.mark.parametrize:
import pytest
@pytest.mark.parametrize("input_val,expected", [
(3, 9),
(0, 0),
(-4, 16),
(2.5, 6.25),
])
def test_square(input_val, expected):
assert square(input_val) == expected
pytest runs test_square four times, once for each row in the parameter table. The test output shows each combination:
test_square[3-9] PASSED
test_square[0-0] PASSED
test_square[-4-16] PASSED
test_square[2.5-6.25] PASSED
When something fails, you can tell exactly which input caused it.
Multiple Parametrised Arguments
Combine several parameters:
@pytest.mark.parametrize("price,discount,expected", [
(100, 10, 90.0),
(50, 100, 0.0),
(33.33, 10, 29.997),
(0, 50, 0.0),
])
def test_apply_discount(price, discount, expected):
result = apply_discount(price, discount)
assert result == pytest.approx(expected, rel=0.01)
pytest generates all combinations by default. Use pytest.mark.parametrize("a", [...]) with another pytest.mark.parametrize("b", [...]) to get a cartesian product.
Parametrised Fixtures
Fixtures can also be parametrised:
@pytest.fixture(params=["json", "csv", "parquet"])
def data_file(tmp_path, request):
"""Create a data file in three formats, run tests three times."""
data = "name,revenue\nAlice,100\nBob,200\n"
ext = request.param
path = tmp_path / f"data.{ext}"
path.write_text(data)
return path
Every test using this fixture runs three times — once per parameter value. request.param gives you the current parameter value inside the fixture.
Combining Fixtures and Parametrisation
Fixtures and parametrize work together:
@pytest.fixture
def sample_dataframe():
import pandas as pd
return pd.DataFrame({
"name": ["Alice", "Bob", "Carol", "Dave"],
"revenue": [100, 200, 150, 300],
"dept": ["sales", "sales", "eng", "eng"]
})
@pytest.mark.parametrize("dept", ["sales", "eng", "unknown"])
def test_avg_revenue_by_dept(sample_dataframe, dept):
result = avg_revenue(sample_dataframe, dept)
if dept == "unknown":
assert result is None
else:
assert result is not None
The fixture provides the DataFrame. The parametrize runs the test against each department. You get the same fixture data with different test inputs.
Indirect Parametrisation
Pass a parametrised value to a fixture instead of directly to the test:
@pytest.fixture
def api_response(api_config):
return requests.get(f"http://localhost:8000/api/{api_config['endpoint']}")
@pytest.mark.parametrize("api_config", [
{"endpoint": "users"},
{"endpoint": "orders"},
{"endpoint": "products"},
], indirect=True)
def test_api_endpoints(api_response, api_config):
assert api_response.status_code == 200
The fixture receives the api_config dict, not the test. indirect=True tells pytest to pass the parametrised value through the fixture first. This pattern is useful when the fixture does setup based on the parameter.
autouse Fixtures
An autouse fixture runs before every test automatically, no explicit fixture argument needed:
import logging
@pytest.fixture(autouse=True)
def suppress_logs():
logging.getLogger("myapp").setLevel(logging.CRITICAL)
This is useful for:
- Suppressing noisy log output during tests
- Resetting global state between tests
- Automatically creating a temp directory for any test that needs it
Be careful with autouse=True — it affects every test in scope, so don’t use it for expensive operations.
Fixture Best Practices
Keep fixtures focused. A fixture should do one thing. If you find yourself passing many optional arguments, the fixture is doing too much.
Name fixtures for what they provide. sample_dataframe is better than df. authenticated_client is better than client.
Use scope to control cost. A database connection that costs 50ms to establish should probably be module-scoped. A temp file that costs microseconds can stay function-scoped.
Don’t mutate fixtures. Fixtures are shared across tests within their scope. If a test modifies a fixture, it affects subsequent tests. Copy data into local variables before modifying.
See Also
- /tutorials/python-testing/testing-pytest-basics/ — the basics of pytest assertions and test discovery
- /guides/pytest-basics/ — pytest configuration, plugins, and command-line options
- /guides/mocking-with-pytest/ — replacing slow or external dependencies with test doubles