Fixtures and Parameterised Tests

9 min read

Most tests share setup. They need an authenticated API client, a clean database, a freshly launched browser. They also tend to repeat themselves — the same shape of test, ten times, with different inputs. pytest's two killer features handle both: fixtures for shared setup/teardown and parametrize for data-driven tests. This lesson covers both, plus conftest.py, fixture scope, autouse, and the patterns you'll use to avoid boilerplate in real test code.

What a fixture is

A fixture is a function decorated with @pytest.fixture that produces a value tests can ask for by parameter name:

import pytest
 
@pytest.fixture
def admin_user():
    return {"name": "Alice", "email": "alice@test.com", "role": "admin"}
 
 
def test_admin_has_role(admin_user):
    assert admin_user["role"] == "admin"
 
def test_admin_email_format(admin_user):
    assert "@" in admin_user["email"]

Read it in two pieces:

  • @pytest.fixture marks the function as a fixture. It's a normal Python function otherwise.
  • The test parameter admin_user matches the fixture's name. pytest sees the parameter, calls admin_user(), and passes the return value into the test.

This is dependency injection — you ask for what you need, pytest figures out how to make it. No setUp() to remember; no global state to reset.

yield for setup and teardown

For fixtures that need cleanup (close a browser, drop a temp DB), use yield instead of return:

import pytest
from helpers.api_client import ApiClient
 
@pytest.fixture
def api_client():
    client = ApiClient("https://staging.api.example.com")
    client.login("admin@test.com", "password")
 
    yield client                         # ← test runs here
 
    client.logout()                      # teardown
 
 
def test_get_users(api_client):
    users = api_client.get("/users")
    assert len(users) > 0

Code before yield is setup; the fixture's value is whatever you yield; code after yield is teardown — guaranteed to run even if the test fails.

yield vs return: a return fixture is fine if there's nothing to clean up; the moment you need teardown, switch to yield. The shape on the test side is identical.

Fixture scope — share work across tests

By default fixtures run per test (scope="function"). Pass a different scope when the setup is expensive:

@pytest.fixture(scope="session")
def base_url():
    return "https://staging.myapp.com"
 
@pytest.fixture(scope="session")
def browser():
    pw = sync_playwright().start()
    chromium = pw.chromium.launch()
    yield chromium
    chromium.close()
    pw.stop()

Five scopes, from narrowest to widest:

ScopeLives for…
functionOne test (the default)
classAll tests in one class
moduleAll tests in one .py file
packageAll tests in one package
sessionThe entire test run

The trade-off is isolation versus speed. function scope guarantees no test pollutes another; session scope is cheapest but means a misbehaving test can corrupt state for everything that follows.

A common pattern: launch the browser at session scope, but create a fresh browser context (incognito-style) per test. Speed plus isolation.

conftest.py — share fixtures without imports

Fixtures live where you can use them. Put them in a conftest.py next to (or above) your tests, and pytest will auto-discover them — no import needed:

my_tests/
├── conftest.py
├── tests/
│   ├── conftest.py
│   ├── test_login.py
│   └── test_users.py
# my_tests/conftest.py — shared by every test
import pytest
 
@pytest.fixture(scope="session")
def base_url():
    return "https://staging.myapp.com"
# my_tests/tests/test_login.py
def test_homepage_loads(base_url):
    # base_url is visible here even though we didn't import anything
    ...

The deeper rule: when pytest is collecting tests/test_login.py, it walks up the directory tree and registers the fixtures defined in every conftest.py it finds. Fixtures in tests/conftest.py are visible only to tests under tests/; fixtures in the root conftest.py are visible everywhere.

This is the canonical place for shared fixtures — base_url, api_client, browser, db_connection, anything more than one test needs.

Fixtures depend on other fixtures

Fixtures can request fixtures by adding them as parameters — pytest resolves the chain:

@pytest.fixture(scope="session")
def base_url():
    return "https://staging.myapp.com"
 
@pytest.fixture(scope="session")
def api_client(base_url):
    client = ApiClient(base_url)
    client.login("admin", "pass")
    yield client
    client.logout()
 
@pytest.fixture
def admin_session(api_client):
    """A logged-in session, refreshed per test."""
    return api_client
 
 
def test_get_users(admin_session):
    users = admin_session.get("/users")
    assert len(users) > 0

admin_session requests api_client; api_client requests base_url. pytest builds the dependency graph and runs each fixture exactly once per its scope.

autouse=True — fixtures that always run

Pass autouse=True to make a fixture apply to every test in scope, even tests that don't ask for it:

@pytest.fixture(autouse=True)
def log_test_name(request):
    print(f"\n--- {request.node.name} ---")
    yield
    print(f"--- finished {request.node.name} ---")

Use sparingly — autouse is global mutation, the same kind that made setUp/tearDown chains hard to follow in older frameworks. Save it for cross-cutting concerns (logging, timing, environment cleanup) where every test genuinely benefits.

Parametrize — one test, many inputs

@pytest.mark.parametrize turns a single test function into N tests, one per input row:

import pytest
from helpers import login
 
@pytest.mark.parametrize("email,password,expected", [
    ("admin@test.com",  "AdminPass",  "success"),
    ("user@test.com",   "UserPass",   "success"),
    ("wrong@test.com",  "WrongPass",  "error"),
    ("",                "password",   "error"),
    ("user@test.com",   "",           "error"),
])
def test_login(email, password, expected):
    result = login(email, password)
    assert result["status"] == expected

pytest expands this into five separate tests:

test_login[admin@test.com-AdminPass-success] PASSED
test_login[user@test.com-UserPass-success]   PASSED
test_login[wrong@test.com-WrongPass-error]   PASSED
test_login[-password-error]                  PASSED
test_login[user@test.com--error]             PASSED

Each row is its own test in the report — failures show which row broke, not just "the test failed." That granularity is the entire point.

The first argument is a comma-separated string of parameter names. The second is a list of tuples, one per row. Match the count and order.

Adding readable IDs

The auto-generated test IDs (test_login[admin@test.com-AdminPass-success]) get hard to read with long values. Use pytest.param(..., id="...") to label rows:

@pytest.mark.parametrize("email,password,expected", [
    pytest.param("admin@test.com", "AdminPass", "success", id="admin_logs_in"),
    pytest.param("",               "AdminPass", "error",   id="empty_email"),
    pytest.param("admin@test.com", "",          "error",   id="empty_password"),
])
def test_login(email, password, expected):
    ...

Now the report reads test_login[admin_logs_in] — far easier when scanning a failure list.

Stacking multiple parametrize decorators

Two parametrize decorators on one function produce the Cartesian product of inputs:

@pytest.mark.parametrize("browser", ["chromium", "firefox", "webkit"])
@pytest.mark.parametrize("viewport", [(1920, 1080), (1366, 768)])
def test_homepage_loads(browser, viewport):
    ...

That's 3 × 2 = 6 tests — every combination. Useful for browser/resolution matrices; deadly if you accidentally stack three or four sets of variants and end up with hundreds of tests. Watch the multiplication.

Parametrize plus fixtures — the real power

Parametrize provides the data; fixtures provide the infrastructure. The combination is what real test suites are built on:

@pytest.fixture(scope="session")
def api_client(base_url):
    return ApiClient(base_url)
 
@pytest.mark.parametrize("role,can_admin", [
    ("admin",   True),
    ("tester",  False),
    ("viewer",  False),
])
def test_admin_access(api_client, role, can_admin):
    user = api_client.create_user(role=role)
    response = api_client.get(f"/admin/dashboard", as_user=user)
    assert response.ok == can_admin

The session-scoped api_client is built once. parametrize then runs the test three times with different roles. Three tests, one expensive setup, no duplication.

Indirect parametrize on a fixture

You can also parametrize a fixture itself, so every test that uses it runs once per parameter:

@pytest.fixture(params=["chromium", "firefox", "webkit"])
def page(request, playwright):
    browser = playwright[request.param].launch()
    page = browser.new_page()
    yield page
    browser.close()
 
 
def test_homepage(page):     # automatically runs three times
    page.goto("https://example.com")
    assert "Example" in page.title()

Every test that takes page runs once per browser. This is how pytest-playwright works under the hood for --browser chromium --browser firefox.

Parametrize, drawn

Five rows in the parametrize, five test instances in the report. When a row fails, the report points at the exact row — which email/password combination broke — not just "the test".

A complete worked example

A small but realistic shape — conftest.py, fixtures with scope, and a parametrized test:

# conftest.py
import pytest
from helpers.api_client import ApiClient
 
@pytest.fixture(scope="session")
def base_url():
    return "https://staging.api.example.com"
 
@pytest.fixture(scope="session")
def api_client(base_url):
    client = ApiClient(base_url)
    client.login("admin@test.com", "AdminPass")
    yield client
    client.logout()
 
@pytest.fixture
def fresh_user(api_client):
    user = api_client.create_user(role="tester")
    yield user
    api_client.delete_user(user["id"])
# tests/test_user_lifecycle.py
import pytest
 
@pytest.mark.parametrize("role,can_admin", [
    pytest.param("admin",  True,  id="admin"),
    pytest.param("tester", False, id="tester"),
    pytest.param("viewer", False, id="viewer"),
])
def test_role_grants_admin_access(api_client, fresh_user, role, can_admin):
    api_client.update_user(fresh_user["id"], role=role)
    response = api_client.get(f"/admin/dashboard", as_user=fresh_user)
    assert response.ok == can_admin

Three tests, one shared session, one fresh user per row, automatic teardown. Every line is the test; no plumbing.

⚠️ Common mistakes

  • return instead of yield when teardown is needed. A fixture using return cannot run cleanup. The browser stays open, the temp file stays on disk. Switch to yield and put the cleanup after the yield line.
  • Wrong fixture scope, then wondering why state leaks. scope="session" means one instance for the whole run — if your test mutates the value, the next test sees the mutation. For mutable per-test state (a fresh user, a clean cart), keep the default function scope.
  • Forgetting that parametrize multiplies fixtures, not just tests. A parametrize on a function-scope fixture means the fixture is built fresh per parameter. Stack two parametrize decorators on a parametrized fixture and you get setup work multiplied — quickly painful for expensive setups. Keep parametrize on the test, not the heavy fixture.

🎯 Practice task

Write a parametrized suite with fixtures. 30 minutes.

  1. In your venv, ensure pytest is installed.
  2. Create helpers.py. Define def add(a, b): return a + b and def divide(a, b): return a / b.
  3. Create conftest.py with a @pytest.fixture called numbers that returns {"x": 10, "y": 5}.
  4. Create test_math.py. Write one test that asks for the numbers fixture and asserts add(numbers["x"], numbers["y"]) == 15.
  5. Add a parametrize: @pytest.mark.parametrize("a,b,expected", [(1, 2, 3), (-1, 1, 0), (0, 0, 0)]) on a test_add(a, b, expected). Run with pytest -v and confirm three test rows.
  6. Add labels with pytest.param(..., id="positives") etc. Confirm the report shows your IDs.
  7. Add a fixture with yieldtemp_log — that creates a file at output/test.log, yields the path, and on teardown deletes it. Use it in a test that writes a line to the file. Confirm the file is gone after the test.
  8. Add a @pytest.fixture(autouse=True) called log_name(request) that prints the test name before and after. Run with pytest -v -s (the -s keeps prints visible) and verify the log lines.
  9. Stack two parametrize decorators on a test_divide(a, b) to produce a small Cartesian matrix. Run with pytest -v and verify the count is len(a) * len(b).
  10. Stretch: turn the numbers fixture into a params=[(10,5), (4,2), (3,3)] parametrized fixture. Every test that uses numbers now runs three times automatically.

You can now share setup across tests and run the same logic over many inputs. The next lesson digs into assertions — including pytest.raises, approximate comparisons, and your own custom matchers.

// tip to track lessons you complete and pick up where you left off across devices.