Pytest & CI

Parametrizing Fixtures with params and ids

Some variation belongs to the environment rather than to any one test. Every repository test should run against both the SQL and the in-memory implementation; every serializer test should run for JSON and MessagePack; every client test should run with and without a proxy. Writing @pytest.mark.parametrize on each of those tests duplicates the list and drifts. Parametrizing the fixture states the variation once, and every test that depends on it — directly or through other fixtures — runs once per value automatically.

That reach is the feature and the hazard. A parametrized fixture three levels down the dependency graph silently multiplies every test above it, and a suite can double in runtime from a one-line change nobody thought of as a performance change. Explicit ids and a quick count keep both the benefit and the cost visible.

Prerequisites

Solution

Python
import pytest

from myapp.repositories import InMemoryInvoiceRepository, SqlInvoiceRepository


@pytest.fixture(
    params=[
        pytest.param("memory", id="memory"),
        pytest.param("sql", id="sql", marks=pytest.mark.integration),
    ]
)
def invoice_repository(request, db_session):
    # request.param carries the current value; each dependent test runs once per value.
    if request.param == "memory":
        return InMemoryInvoiceRepository()
    return SqlInvoiceRepository(db_session)


def test_saved_invoice_is_retrievable(invoice_repository, an_invoice):
    invoice_repository.save(an_invoice)
    assert invoice_repository.get(an_invoice.id) == an_invoice
Plain text
tests/test_repo.py::test_saved_invoice_is_retrievable[memory] PASSED
tests/test_repo.py::test_saved_invoice_is_retrievable[sql]    PASSED

The integration mark on the sql parameter is the detail that makes this practical: pytest -m "not integration" runs only the in-memory variant in the fast suite, and the merge queue runs both, with one fixture and one test body.

How a parametrized fixture multiplies dependent tests A repository fixture with two params, memory and sql, is requested by three tests directly and by a service fixture that two further tests request. All five tests are therefore collected twice, producing ten items, each with the parameter id in its name. One fixture's params reach every test above it invoice_repository params: memory, sql 3 tests request it directly billing_service fixture → 2 more tests 10 items collected 5 tests × 2 params
The two tests reached through billing_service are the ones people forget: they never mention the repository, yet they run twice.

Why this works

During collection, pytest walks each test's fixture closure — every fixture it requests, and every fixture those request. When it finds a fixture with params, it generates one test item per parameter, attaching the value as request.param and appending the parameter's id to the item's name. Because this happens over the whole closure, the variation propagates upward through any number of intermediate fixtures without those fixtures or tests declaring anything.

Scope is respected per parameter. A session-scoped parametrized fixture is instantiated once per parameter, and pytest reorders the collected items so that all tests using one parameter run together — which is why a session fixture with two expensive params is set up twice per run rather than once per test.

Edge cases and failure modes

  • Unreadable ids. Without explicit ids, pytest uses the value if it is a simple type and request.param index names such as invoice_repository0 otherwise. Always pass ids.
  • Accidental multiplication. A parametrized fixture added deep in the graph doubles every test above it. Count collected items before and after the change.
  • Parameters that must not combine. Two parametrized fixtures in one closure produce the Cartesian product. Where some combinations are meaningless, mark them skip rather than letting them run and fail.
  • One test that should see only one parameter. Use indirect parametrization on that test to override the fixture's params, or depend on a narrower fixture.
  • Session scope with expensive params. Each parameter's setup runs once, but pytest's reordering can change test order in surprising ways. Do not depend on ordering across modules.

When two parametrized fixtures meet

A single parametrized fixture multiplies by its parameter count. Two in the same closure multiply by each other, and the result is the Cartesian product — which is sometimes exactly the matrix you want and sometimes a set of combinations that make no sense together.

Consider a repository fixture with memory and sql, and a serializer fixture with json and msgpack. A test requesting both runs four times, and all four combinations are meaningful: every storage backend should work with every wire format. That is the case fixture params were designed for, and the product is the whole point.

Now add a cache fixture with none and redis, where the Redis cache only makes sense alongside the SQL repository. The product is eight combinations, two of which pair an in-memory repository with a Redis cache — valid Python, meaningless architecture. Those two should not run. The clean expression is a mark that skips them with a reason, applied in a pytest_collection_modifyitems hook or a small fixture that inspects both parameters and calls pytest.skip, so the report records the exclusion explicitly instead of silently producing tests that assert nothing useful.

Python
import pytest


@pytest.fixture
def cache(request, invoice_repository):
    backend = request.param
    if backend == "redis" and isinstance(invoice_repository, InMemoryInvoiceRepository):
        pytest.skip("redis cache is only deployed with the SQL repository")
    return make_cache(backend)

The skip appears in the summary with its reason, which is the property that matters: someone reading the report can see that the combination was considered and excluded, rather than wondering why it is missing.

A Cartesian product with excluded combinations A grid of repository parameters against cache parameters. Memory with no cache, SQL with no cache and SQL with Redis run. Memory with Redis is marked as skipped with a stated reason, so the report records that the combination was deliberately excluded. The product, minus the combinations that never ship cache = none cache = redis repo = memory repo = sql runs skipped "only deployed with SQL" runs runs
A skip with a reason documents the exclusion in every report; a silently absent combination leaves the next reader guessing whether it was forgotten.

Choosing fixture params over the alternatives

Fixture params are one of three ways to run tests under several configurations, and the choice among them follows from where the variation lives.

When the variation belongs to one test — a set of inputs and expected outputs for a single function — @pytest.mark.parametrize on that test is clearest, because the cases sit next to the assertion that uses them. When the variation belongs to the environment and every test depending on some resource should see all of it, fixture params are clearest, because the list is declared once where the resource is built. And when the variation is expensive enough that it should not run on every push — a second database engine, a second interpreter — it belongs in the CI matrix rather than in pytest at all, with an environment variable selecting the configuration per job.

Mixing the three deliberately is normal. A suite might parametrize a parser test over twenty input strings, parametrize the repository fixture over two backends, and run the whole thing on three Python versions in CI. What goes wrong is using the wrong one: a CI matrix entry for something every developer should run locally, or a fixture param for something only one test cares about. Placing each variation at the level where it genuinely lives keeps the suite fast where it should be fast and thorough where it should be thorough. It also makes each variation easy to find: the reader looking for why a test runs twice has one obvious place to look. That discoverability is worth as much as the speed. A suite where every variation lives in the right layer is one where adding a new backend or format is a one-line change in one place.

Keeping the multiplication honest

The cost of a parametrized fixture is not visible in the diff that adds it. It shows up as a suite that is suddenly slower, and the change responsible looks innocuous.

A one-line check makes it visible at review time. Record the collected-item count on the main branch in CI, and fail or warn when a pull request changes it by more than a threshold without the change description explaining why. The collection is fast — no tests run — and the number is a surprisingly good proxy for suite cost.

Bash
pytest --collect-only -q | tail -1
Plain text
1284 tests collected in 3.41s

When the count jumps from 1284 to 2568, the pull request that parametrized a low-level fixture is identified immediately, and the conversation about whether every dependent test really needs both variants happens before the merge rather than after the pipeline doubles in length. Often the answer is that a handful of tests should use both backends and the rest should pin one, which indirect parametrization or a second, non-parametrized fixture expresses cleanly.

Collected-item count as a guard against silent multiplication A pull request adds params to a low-level fixture. The collected-item count doubles from 1284 to 2568. A CI step comparing the count against the main branch flags the change at review time, prompting a decision about which tests genuinely need both parameters. Make the cost visible before merge main branch 1,284 items pull request 2,568 items CI check +100% — explain or narrow Collection runs no tests, so the check costs seconds — and it catches the one-line change that would otherwise double the pipeline without anyone noticing why.
The number is crude, but a doubling is never an accident worth merging unexamined.

Frequently Asked Questions

What is the difference between fixture params and @pytest.mark.parametrize?parametrize varies a test's arguments and applies to one test. Fixture params vary a fixture, and every test that requests that fixture — directly or through another fixture — is run once per parameter. Use fixture params when the variation is a property of the environment rather than of one test.

How do I mark one parameter as xfail or skip? Wrap it in pytest.param with marks, exactly as with parametrize: params=["sqlite", pytest.param("mysql", marks=pytest.mark.xfail(reason="…"))]. The mark applies to every test instance that receives that parameter.

How does scope interact with params? The fixture is set up once per parameter per scope instance. A session-scoped fixture with three params is created three times per session, and pytest reorders tests to group those sharing a parameter so each is set up only once.

← Back to Mastering pytest Fixtures