pyguides

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 test
  • scope="class" — one fixture per test class
  • scope="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