Article summary

TL;DR

  • Use fixtures to own resources and cleanup, and @pytest.mark.parametrize to create independently reported cases for the same behavior.
  • Choose fixture params for shared resource configurations, factories for several objects within one scenario, and indirect parameters for case-specific setup.
  • Place fixtures in the nearest shared conftest.py; keep mutable state function-scoped and measure before sharing expensive resources.
  • Multiply resource configurations, test rows, and CI jobs to see how many cases will run. A session fixture is shared within each pytest process, including each pytest-xdist worker.
  • Use pytest_generate_tests for configuration-driven cases, with stable ordering, validated inputs, and an explicit empty-dataset policy.

01 / DESIGN DECISIONS

Choose the tool for what varies in your pytest suite.

Scalable pytest fixtures and parametrization start with three separate decisions: the resources a test needs, the input cases it covers, and the state each test owns. Use fixtures for setup and cleanup, parametrization for independently reported cases, and function scope to give each case fresh mutable state. Share a resource only when its reuse is safe and measurement shows a benefit.

That sounds straightforward when the suite has ten tests. With several hundred tests, the same choices determine whether a change requires editing one setup function or twenty files. They also determine whether CI spends its time exercising behavior or repeatedly starting the same resources.

This guide assumes you already write basic pytest tests. We’ll refactor a small inventory suite, run the same reservation contract against two SQLite storage configurations, and extend it with factories, indirect parameters, and dynamically generated cases. Then we’ll measure setup cost and examine what changes when CI uses multiple workers.

The useful first question is what actually varies:

What varies? Start with Example
Inputs and expected results for one behavior @pytest.mark.parametrize Reserve 1, 4, or 5 units and check the balance.
A resource configuration used by several tests @pytest.fixture(params=...) Run the reservation contract against memory and file storage.
The objects needed inside one scenario A fixture that returns a factory Create two products with different opening balances.
Setup selected by particular case rows parametrize(..., indirect=[...]) Build inventory from a row’s opening stock.
Cases supplied by project configuration pytest_generate_tests Select stock quantities through a CLI option.

These tools compose. A test can request a factory fixture and still be parametrized. The choice depends on whether you need another test case, another object within a case, or another resource configuration.

We’ll keep the business contract small: reserving a positive quantity reduces that product’s stock; an invalid or excessive reservation leaves stock unchanged. That contract gives the fixture design something concrete to support.

02 / REFACTORING

Refactor repeated setup and repeated cases separately.

Our example uses Python 3.13 and was verified with pytest 9.1.1. SQLite comes with Python, so the main suite needs no database server.

The complete pytest fixtures and parametrization example on GitHub includes locked dependencies and a scripts/check script for serial and parallel tests, dynamic cases, and scope experiments. GitHub Actions runs the same checks. Follow the repository’s README to run the finished project, or build it step by step below.

In an empty directory, with uv installed, create these files:

pytest-fixtures-parametrization/
  pyproject.toml
  inventory.py
  refactoring/
    before.py
    after.py
  tests/
    conftest.py
    test_reservations.py

Start pyproject.toml with:

[project]
name = "pytest-fixtures-parametrization"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []

[dependency-groups]
dev = ["pytest==9.1.1"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "--strict-config --strict-markers"
empty_parameter_set_mark = "fail_at_collect"

Run uv sync to install dependencies and create uv.lock. Commit the lockfile with the example. Subsequent installations, including CI, can use uv sync --locked.

This deliberately small project imports inventory.py from its root using the declared pythonpath. An installable application should use its normal package installation instead of copying this setting automatically.

The tests use three operations: add a product, reserve some stock, and read its remaining balance. The implementation lets the tests choose and close the SQLite connection. Put it in inventory.py:

import sqlite3


class Inventory:
    """Reserve stock through a caller-owned SQLite connection."""

    def __init__(self, connection: sqlite3.Connection) -> None:
        self.connection = connection
        connection.execute('CREATE TABLE stock (sku TEXT PRIMARY KEY, available INTEGER NOT NULL)')

    def add(self, sku: str, available: int) -> None:
        """Create a product with a nonnegative opening balance."""
        if available < 0:
            raise ValueError('available must not be negative')
        self.connection.execute('INSERT INTO stock VALUES (?, ?)', (sku, available))

    def remaining(self, sku: str) -> int:
        """Read a product's balance, raising KeyError for an unknown product."""
        row = self.connection.execute('SELECT available FROM stock WHERE sku = ?', (sku,)).fetchone()
        if row is None:
            raise KeyError(sku)
        return row[0]

    def reserve(self, sku: str, quantity: int) -> None:
        """Subtract positive stock without permitting an overdraw."""
        if quantity <= 0:
            raise ValueError('quantity must be positive')
        updated = self.connection.execute(
            'UPDATE stock SET available = available - ? WHERE sku = ? AND available >= ?',
            (quantity, sku, quantity),
        )
        if updated.rowcount != 1:
            raise ValueError('stock unavailable')

The connection belongs to the caller. That lets tests choose storage and lifetime without adding pytest dependencies to application code. The conditional update prevents an individual reservation from taking the balance below zero. This tutorial tests reservation behavior; it does not claim to validate production database concurrency or PostgreSQL compatibility.

Before: every case repeats the resource lifecycle

Put this in refactoring/before.py:

import sqlite3
from contextlib import closing

from inventory import Inventory


def test_reserve_one():
    with closing(sqlite3.connect(':memory:', autocommit=True)) as conn:
        inventory = Inventory(conn)
        inventory.add('book', 5)
        inventory.reserve('book', 1)
        assert inventory.remaining('book') == 4


def test_leave_one():
    with closing(sqlite3.connect(':memory:', autocommit=True)) as conn:
        inventory = Inventory(conn)
        inventory.add('book', 5)
        inventory.reserve('book', 4)
        assert inventory.remaining('book') == 1


def test_exhaust_stock():
    with closing(sqlite3.connect(':memory:', autocommit=True)) as conn:
        inventory = Inventory(conn)
        inventory.add('book', 5)
        inventory.reserve('book', 5)
        assert inventory.remaining('book') == 0

All three tests are understandable. The maintenance problem appears when connection setup or initial data changes: the same edit belongs in three places, and that count grows as cases are added.

After: use @pytest.mark.parametrize with a fixture

Put this alternative in refactoring/after.py:

import sqlite3
from contextlib import closing

import pytest

from inventory import Inventory


@pytest.fixture
def stocked_inventory():
    with closing(sqlite3.connect(':memory:', autocommit=True)) as conn:
        inventory = Inventory(conn)
        inventory.add('book', 5)
        yield inventory


@pytest.mark.parametrize(
    ('quantity', 'expected'),
    [
        pytest.param(1, 4, id='reserve-one'),
        pytest.param(4, 1, id='leave-one'),
        pytest.param(5, 0, id='exhaust-stock'),
    ],
)
def test_reserve(stocked_inventory, quantity, expected):
    stocked_inventory.reserve('book', quantity)
    assert stocked_inventory.remaining('book') == expected

The stocked_inventory argument tells pytest which fixture the test needs. Pytest runs that fixture and passes the yielded inventory to the test. The fixture pauses at yield while the test runs, then resumes and exits closing(...), which closes the connection.

@pytest.mark.parametrize creates a separate test item for each row: one execution with its own reported result. The default function scope gives each item a fresh inventory. The case ID, such as exhaust-stock, identifies a failed row in the CI log.

The fixture owns setup and cleanup; the case table owns quantities and expected results. Adding another quantity means adding a row. Changing how inventory is created means editing one fixture.

Concern Before After
Connection setup and cleanup Repeated in three tests Owned by one fixture
Reservation and balance assertion Three copies One test body
Boundary cases Encoded in separate functions Visible together in a case table
Reported test items Three Three
Mutable state Fresh per test Fresh per parametrized case

For three short tests, readable IDs and a fixture can use as many lines as the original. The benefit is fewer places to maintain the same setup and assertions.

Verify both versions explicitly; their filenames keep them outside normal test discovery:

uv run pytest refactoring/before.py refactoring/after.py -q

Both versions passed locally: three cases in each implementation, producing six passing items together.

Keep rejection behavior in separate tests. A parameter table that needs an if to decide whether to assert a balance or catch an exception is often trying to represent several stories at once.

03 / FIXTURE ARCHITECTURE

Combine fixtures and parametrization across modules.

As more modules need inventory, move the shared setup into tests/conftest.py. Keep refactoring/before.py and refactoring/after.py as comparison snapshots; the files under tests/ form the suite we will extend. Split resource creation from scenario data so a test can request an empty inventory or a prepared one.

import sqlite3
from contextlib import closing

import pytest

from inventory import Inventory


@pytest.fixture(params=['memory', 'file'])
def connection(request, tmp_path):
    path = ':memory:' if request.param == 'memory' else tmp_path / 'stock.sqlite3'
    with closing(sqlite3.connect(path, autocommit=True)) as conn:
        yield conn


@pytest.fixture
def inventory(connection):
    return Inventory(connection)


@pytest.fixture
def stocked_inventory(inventory):
    inventory.add('book', 5)
    return inventory

Here, connection is a parametrized fixture. Its params list selects a SQLite database in memory or a temporary file, and request.param contains the selected value during setup. Tests using inventory inherit that variation through their dependency on connection.

autocommit=True makes each SQL statement’s changes immediate in this Python 3.13 example. Isolation comes from a new database for every test item. closing() closes the connection after use; a SQLite connection’s own context manager controls transaction handling and does not close the connection. See Python’s SQLite connection context manager documentation.

The dependency graph below shows setup flowing toward the test. The whole chain is recreated for the next test item:

connection: memory or temporary file
                |
                v
           inventory
                |
                v
       stocked_inventory
         adds book = 5
                |
                v
      test_reserve(case row)

after the item: close its connection
next item: create a fresh database

Save the reservation tests in tests/test_reservations.py:

import pytest


@pytest.mark.parametrize(
    ('quantity', 'expected'),
    [
        pytest.param(1, 4, id='reserve-one'),
        pytest.param(4, 1, id='leave-one'),
        pytest.param(5, 0, id='exhaust-stock'),
    ],
)
def test_reserve(stocked_inventory, quantity, expected):
    stocked_inventory.reserve('book', quantity)
    assert stocked_inventory.remaining('book') == expected


@pytest.mark.parametrize('quantity', [6, 10], ids=['one-too-many', 'double-stock'])
def test_overdraw_preserves_stock(stocked_inventory, quantity):
    with pytest.raises(ValueError, match='stock unavailable'):
        stocked_inventory.reserve('book', quantity)
    assert stocked_inventory.remaining('book') == 5


@pytest.mark.parametrize('quantity', [0, -1], ids=['zero', 'negative'])
def test_invalid_quantity_preserves_stock(stocked_inventory, quantity):
    with pytest.raises(ValueError, match='quantity must be positive'):
        stocked_inventory.reserve('book', quantity)
    assert stocked_inventory.remaining('book') == 5

The rejection tests check both the exception and unchanged stock because together they describe a single rejection contract. They also make shared-state mistakes easier to notice: every case expects its own opening balance of five.

How many cases does the combination create?

For test_reserve, two storage configurations multiplied by three quantity rows produce six test items. The same three quantity rows run against each storage configuration.

Collection is the phase where pytest discovers tests and creates their named test items. Inspect those items before running the cases:

uv run pytest tests/test_reservations.py::test_reserve --collect-only -q

These are the six node IDs from the verified example; the timing footer is omitted:

tests/test_reservations.py::test_reserve[memory-reserve-one]
tests/test_reservations.py::test_reserve[memory-leave-one]
tests/test_reservations.py::test_reserve[memory-exhaust-stock]
tests/test_reservations.py::test_reserve[file-reserve-one]
tests/test_reservations.py::test_reserve[file-leave-one]
tests/test_reservations.py::test_reserve[file-exhaust-stock]

Independent parametrization dimensions form a Cartesian product: every value from one dimension combines with every value from the other. Use that when all combinations are meaningful. When only specific combinations are valid, put them in explicit rows rather than generating a large matrix and skipping most of it.

You can rerun a single reported case by quoting its full node ID:

uv run pytest 'tests/test_reservations.py::test_reserve[file-exhaust-stock]' -q

Where should conftest.py live?

Place a fixture at the nearest directory shared by the tests that need it. When this example becomes part of a larger application, the inventory fixtures might belong here:

tests/
  conftest.py             suite-wide policies
  unit/
    test_pricing.py
  integration/
    conftest.py           connection and inventory fixtures
    test_reservations.py
    test_products.py

Pytest discovers these fixtures automatically. Tests look in their local context and then upward through parent directories; they cannot reach down into a sibling directory’s fixtures. A nearer fixture can override a parent fixture with the same name. Those lookup rules are documented in the pytest fixtures reference.

Directory placement controls who can request a fixture. The fixture’s scope controls how long an instance lives. Putting a fixture in the root conftest.py does not make it session-scoped.

Keep ordinary builders and reusable application helpers in regular Python modules. Import those helpers into fixtures when needed. Importing fixtures from conftest.py into test files obscures the discovery model and makes moving tests harder.

The core suite is now in place: shared fixture definitions, explicit case rows, and fresh state for each run. The remaining sections extend it when you need more flexible setup, faster CI, or cases generated from configuration.

Course preview: extend the pattern through a complete project

For guided practice, the 11+ hour Pytest course applies fixtures and parametrization in Finance Tracker exercises, then continues through GitHub Actions, matrix testing, and coverage gates.

Try the free opening lessons and explore the complete fixtures, parametrization, and CI/CD learning path.

View course

04 / FACTORIES AND INDIRECT SETUP

Use factories for objects within a case and parametrization for cases.

A fixture factory is a fixture that returns a callable. The test calls it to create as many objects as the scenario requires. This is useful when fixed fixtures such as book_with_five_units, pen_with_twenty_units, and empty_product start multiplying.

Add this fixture to tests/conftest.py:

@pytest.fixture
def make_product(inventory):
    def create(*, sku, available=5):
        inventory.add(sku, available)
        return sku

    return create

Then add tests/test_products.py:

def test_reserving_one_product_leaves_another_unchanged(inventory, make_product):
    book = make_product(sku='book', available=5)
    pen = make_product(sku='pen', available=20)

    inventory.reserve(book, 2)

    assert inventory.remaining(book) == 3
    assert inventory.remaining(pen) == 20

This test describes one scenario with two products. Both inventory and make_product use the same resolved inventory instance within that item, so the objects created through the factory are visible to the test. The next item receives a fresh database.

The factory itself does not create additional pytest items. Our two storage configurations still produce two executions of this scenario. Calling make_product twice creates two products inside each execution.

Keep defaults boring and make the scenario’s meaningful differences explicit at the call site. A factory that randomly chooses stock or silently creates related records makes failures harder to explain. If creation requires no managed resource or fixture dependency, an ordinary helper function may be enough.

When does indirect parametrization help?

Use indirect parametrization when a test row describes setup that a fixture must build. Pytest collects the row first; the fixture turns its descriptor into a resource during test setup.

Add another fixture to tests/conftest.py:

@pytest.fixture
def prepared_inventory(request, inventory):
    inventory.add('book', request.param)
    return inventory

Create tests/test_prepared.py:

import pytest


@pytest.mark.parametrize(
    ('prepared_inventory', 'quantity', 'expected'),
    [(5, 2, 3), (10, 4, 6)],
    indirect=['prepared_inventory'],
    ids=['small-order', 'larger-order'],
)
def test_reserve_from_prepared_stock(prepared_inventory, quantity, expected):
    prepared_inventory.reserve('book', quantity)
    assert prepared_inventory.remaining('book') == expected

Only prepared_inventory is indirect. Its row value, 5 or 10, goes to the fixture as request.param. quantity and expected go straight to the test. The test receives the prepared Inventory object rather than the original integer.

The underlying connection fixture still supplies memory and file storage, so two explicit rows produce four items. This is useful when different tests need their own setup tables. A fixture’s params is useful when a shared configuration matrix belongs to the resource and should apply to every consumer.

Avoid constructing live clients, opening databases, or creating records directly inside a parameter list. That code executes when the module is imported for collection. Use cheap descriptions such as strings, integers, or immutable case records, then build resources in fixtures. The official parametrization examples document the indirect mechanism.

Don’t add indirect setup to every test. For a scenario that already creates several products through make_product, an ordinary parameter row passed into that factory can be easier to read.

05 / SCOPE AND CLEANUP

Choose fixture scope by ownership before optimizing reuse.

A fixture’s scope defines its reuse lifetime. Wider scope can avoid repeated setup, but every consumer then has to tolerate receiving shared state.

Scope Lifetime A reasonable use when sharing is safe
function One test item Mutable records, an isolated connection, or a temporary workspace
class One test class A costly resource used by that class
module One test module An immutable dataset used throughout the module
package The relevant test package A resource shared within a real package boundary
session One pytest process’s test session Immutable configuration or expensive infrastructure

Use your suite’s package structure and observed fixture lifetime to decide whether package is the right boundary.

Share infrastructure while keeping mutable state private

Our inventory connection should remain function-scoped. Making it session-scoped would let one reservation affect later tests. It would also make Inventory(connection) attempt to create an existing table on later requests.

There is a second issue: connection depends on the function-scoped tmp_path. A longer-lived fixture cannot depend on a shorter-lived fixture. Pytest reports a ScopeMismatch rather than choosing an arbitrary lifetime. A session-level temporary directory would use tmp_path_factory, but changing the path fixture alone would not solve the shared stock problem.

For an expensive external database, separate the server’s lifetime from each test’s data. One possible architecture is:

pytest process / worker
  |
  +-- database server or pool     session lifetime
        |
        +-- isolated data A      test A lifetime
        |     +-- records and client
        |     +-- test A
        |     +-- rollback or delete data A
        |
        +-- isolated data B      test B lifetime
              +-- records and client
              +-- test B
              +-- rollback or delete data B

session ends: stop server or close pool

The diagram is a design option for a larger suite, not an additional behavior of our SQLite example. An isolated schema or database per test is one approach; a transaction with verified rollback is another. Choose based on what the application actually does.

Rollback isolation works only when application writes participate in the test’s transaction. Separate connections, background jobs, and commits that escape the test harness can leave data behind. Verify isolation with the real framework integration before widening scope.

Share the expensive resource only when each test can still establish and clean up the state it owns.

Cleanup must survive partial setup failures

Keep resource acquisition and cleanup close together. In our connection fixture, the closing() context is entered before yielding the connection. If a dependent fixture or test fails, pytest finalization resumes the fixture and closes the connection.

Be more careful with a fixture that acquires several resources before reaching its yield. An exception during setup can prevent code placed only after that yield from running. Use context managers around each acquired resource, or split the resources into dependent fixtures so completed setup has its own cleanup.

For dependent fixtures, cleanup unwinds the dependency chain. Do not rely on source-code order or the order of function arguments to express a required setup sequence. Make the dependency explicit. The fixture teardown guide explains pytest’s finalization behavior.

Also avoid treating a parametrized session fixture as a permanent cache of every parameter value. Pytest caches one active instance of a fixture at a time; a parametrized fixture can be recreated within its declared scope. Count actual setup when reuse is important.

06 / CI/CD PERFORMANCE

Measure fixture setup before changing the CI/CD pipeline.

Start by separating collection, setup, test execution, and teardown. A suite with slow imports needs different work from a suite that opens a new remote connection for every case.

Use these commands against the example:

uv run pytest --collect-only -q
uv run pytest tests/test_reservations.py::test_reserve --setup-plan -q
uv run pytest --durations=20 --durations-min=0 -q

Collection shows how many items you’ve created. --setup-plan shows the planned fixture lifecycle without executing tests or fixture bodies. Durations report slow setup, call, and teardown phases; they are a starting point for finding expensive setup, not a per-fixture profiler. See pytest’s duration reporting documentation.

A reproducible setup-cost experiment

This optional experiment isolates setup cost with a simulated catalog load. If you are tuning an existing suite, start with the duration report above; the experiment explains how reuse can help.

Create benchmarks/conftest.py. Its helpers select scope from a CLI option and print timing totals. config.stash stores the measurements for this pytest process, and StashKey identifies that stored list. The catalog fixture supplies the data and records each setup; pytest_terminal_summary prints the count and elapsed time:

from time import perf_counter, sleep

import pytest

SETUP_TIMES = pytest.StashKey[list[float]]()


def pytest_addoption(parser):
    parser.addoption('--catalog-scope', choices=['function', 'session'], default='function')


def catalog_scope(*, fixture_name, config):
    return config.getoption('catalog_scope')


@pytest.fixture(scope=catalog_scope)
def catalog(request):
    started = perf_counter()
    sleep(0.1)  # Controlled setup cost, not a production benchmark.
    result = tuple(range(10))
    elapsed = perf_counter() - started
    request.config.stash.setdefault(SETUP_TIMES, []).append(elapsed)
    return result


def pytest_terminal_summary(terminalreporter, config):
    times = config.stash.get(SETUP_TIMES, [])
    terminalreporter.write_line(f'catalog setups={len(times)}; measured setup={sum(times):.3f}s')

Add benchmarks/test_scope_cost.py:

import pytest


@pytest.mark.parametrize('product', range(10))
def test_catalog_contains_product(catalog, product):
    assert product in catalog

Run the experiment in one process, once with each scope:

uv run pytest benchmarks -q --catalog-scope=function
uv run pytest benchmarks -q --catalog-scope=session

These are measured results from one local run with Python 3.13.12 and pytest 9.1.1:

Scope Passing items Catalog setups Measured time inside catalog setup
Function 10 10 1.039 seconds
Session 10 1 0.105 seconds

Ten setups became one: 90% fewer invocations and approximately 89.9% less measured setup time. Each invocation deliberately sleeps for 0.1 seconds, so this measures reuse of a fixed setup cost. It does not predict a production suite’s speedup, and your timings will vary with scheduling and machine load.

For one unparametrized resource in a serial run, the setup-cost model is simple:

per-test setup cost = number of consumers * setup cost
shared setup cost   = 1 * setup cost + per-test isolation costs

If real setup is 20% of the job and you remove 90% of that component, the theoretical saving is 18% of the original job time before new isolation costs. Dependency installation, test bodies, and reporting do not disappear when a fixture is reused.

Parallel workers change the sharing boundary

Install pytest-xdist if you want to test worker behavior:

uv add --dev 'pytest-xdist==3.8.0'
uv run pytest tests -n 2 -q

A session fixture is shared within a worker process. It does not automatically run once across all workers. Three independent Python-version jobs with four workers each can create twelve instances of a resource used once in every worker. Parameter changes or worker restarts can add more. The pytest-xdist session fixture guidance explains this process boundary.

Give workers independent database names, file paths, queues, or other mutable resources. tmp_path supplies isolated temporary paths for this example. For resources outside the local filesystem, include an identifier for the CI run as well as the worker so simultaneous jobs cannot collide.

If expensive setup belongs to a module or class, evaluate --dist=loadscope. It keeps the relevant test groups on the same worker, which can reduce repeated setup, but a very large group can leave other workers idle. Compare elapsed time and resource use before adopting it. See the xdist scheduling options.

The example’s main suite passed both serially and with two workers. It is small enough that worker startup makes the parallel run slower. Treat that as a reminder to measure at your suite’s scale, with representative CI resources.

Count the matrix before expanding it

Two resource configurations, three quantity cases, three Python versions, and two operating systems mean 36 executions of the same parametrized test across the pipeline. Workers distribute those executions; the normal xdist scheduler does not multiply each test by the worker count.

Review each dimension against a support requirement. A library supporting several interpreters may need the full compatibility matrix. A service deployed on one interpreter may need a smaller matrix and more attention to database behavior.

Once the fixture and case boundaries are sound, the GitHub Actions pytest CI guide shows how to add caching, reports, a Python matrix, and a stable required check. Run the same locked dependency installation and pytest command locally and in CI.

07 / DYNAMIC PARAMETRIZATION

Use pytest_generate_tests when configuration determines the cases.

Static parameter rows are the easiest cases to review. A collection hook becomes useful when the suite must derive cases from a command-line option or a versioned data file.

Add these hooks to tests/conftest.py, where pytest is already imported:

def pytest_addoption(parser):
    parser.addoption(
        '--opening-stock',
        action='append',
        type=int,
        default=[],
        help='Opening stock for dynamic cases; repeat for multiple cases',
    )


def pytest_generate_tests(metafunc):
    if 'opening_stock' in metafunc.fixturenames:
        values = metafunc.config.getoption('opening_stock') or [1, 5]
        if any(value < 1 for value in values):
            raise pytest.UsageError('--opening-stock must be positive')
        values = sorted(set(values))
        metafunc.parametrize('opening_stock', values, ids=[f'stock-{value}' for value in values])

Then add tests/test_dynamic.py:

def test_reserve_all_available(inventory, opening_stock):
    inventory.add('book', opening_stock)
    inventory.reserve('book', opening_stock)
    assert inventory.remaining('book') == 0

Run the default cases, or supply an explicit set:

uv run pytest tests/test_dynamic.py -q
uv run pytest tests/test_dynamic.py --opening-stock=3 --opening-stock=8 -q

The default uses opening stocks of 1 and 5. The second command uses 3 and 8. Each produces four passing items because two opening balances combine with two storage configurations. The test checks the same behavior each time: reserving all available stock leaves zero.

pytest_generate_tests runs during collection for test functions. The metafunc.fixturenames check limits this parametrization to consumers of opening_stock. Validation rejects nonpositive values, and sorting deduplicated values gives collection a stable order. No database work happens in the hook; the ordinary fixtures own it later.

This is a project policy: repeated values collapse into one case and omitted options use the default set. If duplicates carry meaning in your application, use explicit case identifiers instead of deduplication.

Keep dynamically generated suites reproducible

For CSV or JSON cases, use a versioned local file, validate the complete input, and generate stable IDs. An HTTP request during collection makes test discovery depend on connectivity, credentials, and mutable remote data. If a remote dataset is necessary, fetch and validate a snapshot before pytest starts, then give every worker the same snapshot.

Pytest-xdist requires workers to collect the same items in the same order. Unordered sets, unsorted file discovery, timestamps, or random case generation can violate that contract. The xdist collection limitations describe why this matters.

Decide what an empty dataset means. Pytest’s default empty-parameter behavior can produce a skip; our configuration uses empty_parameter_set_mark = "fail_at_collect" so a missing required case set is a collection error. The hook shown above supplies defaults when the CLI option is omitted, so that policy becomes relevant if you later replace the defaults with a file-derived list.

Don’t parametrize opening_stock again with a decorator on the same test. Give each argument one source of case generation. Hook complexity is worthwhile when it expresses a real collection policy, and expensive when it simply hides a small table that could live beside the assertion. The pytest parametrization guide documents this hook’s role.

08 / SCALING MISTAKES

Common questions and mistakes when scaling pytest.

Most fixture problems become visible as confusing failures, unexpected case counts, or tests that pass only in a particular order. These symptoms give you a place to start:

Symptom Likely design problem A useful first change
A test passes alone and fails in the suite Shared mutable state or incomplete cleanup Recreate the affected state per item and run the suspected pair in both orders.
A unit test opens a database unexpectedly Broad autouse setup Move resource fixtures to the tests that need them and request them explicitly.
A fixture breaks when moved between directories Hidden lookup or an unintended override Inspect the nearest conftest.py definitions and use a domain-specific name.
An assertion is buried beneath many setup layers A fixture graph that mixes resources and scenarios Keep resource ownership in fixtures and meaningful scenario choices in the test.
Collection grows much faster than the requirements Unnecessary products of parameter dimensions Replace correlated dimensions with explicit valid rows.
A case changes values seen by another case Reused mutable parameter objects Parametrize immutable descriptions and build fresh objects during setup.
Collection fails only with workers Different case sets or ordering Use one validated input snapshot and deterministic ordering.

Autouse fixtures are useful for a genuinely universal policy, such as blocking unexpected network access in unit tests. A database account or logged-in client is usually a scenario dependency that should be visible in the test signature.

Parameter values deserve the same isolation review as fixtures. Pytest passes them as supplied; it does not copy lists or dictionaries before each invocation. Reusing a mutable object across rows or parameter dimensions can carry mutations into later calls. Prefer integers, strings, tuples of immutable values, or fresh objects created from descriptors.

When a failure is hard to trace, inspect the fixtures used by that exact node ID:

uv run pytest 'tests/test_reservations.py::test_reserve[file-exhaust-stock]' --fixtures-per-test -q

The goal is to answer four questions without reading the whole suite: where did this object come from, what varies in this case, who else can mutate its state, and who cleans it up?

If the underlying assertions protect incidental implementation details, fixture cleanup alone will not make the suite maintainable. The Python unit testing best-practices guide covers choosing stable behavior boundaries and useful failure cases.

Why did parametrization replace my fixture?

Direct parametrization supplies the named argument’s value itself. If you write @pytest.mark.parametrize("inventory", [5]), the test receives the integer 5 under that name instead of requesting the inventory fixture. Use a different argument name for ordinary case data, or use an appropriate fixture with indirect=["inventory"] when the value should configure setup. Our prepared_inventory fixture handles that second role explicitly.

Can pytest fixtures take arguments from a test?

A fixture receives other fixtures by name through its signature. A test does not call the decorated fixture directly with runtime arguments. Request a factory fixture when you need calls such as make_product(sku="pen", available=20) inside the test, or pass collection-time descriptors through indirect parametrization. Keep an ordinary helper function when pytest does not need to manage its lifetime.

Run the completed example

After adding all the article’s snippets, the project has these files:

pytest-fixtures-parametrization/
  pyproject.toml
  uv.lock
  inventory.py
  refactoring/
    before.py
    after.py
  tests/
    conftest.py
    test_reservations.py
    test_products.py
    test_prepared.py
    test_dynamic.py
  benchmarks/
    conftest.py
    test_scope_cost.py

Run the suite from the project root:

uv run pytest --collect-only -q
uv run pytest -q

With exactly the snippets above and the default opening-stock values, both commands cover 24 test items. Collection lists them; the second command runs them. All 24 passed when the article’s snippets were assembled and tested together.

The GitHub project also includes product validation tests, so its default suite runs 32 items.

testpaths = ["tests"] keeps the comparison and timing examples outside the default run. Invoke those separately when you want them:

uv run pytest refactoring/before.py refactoring/after.py -q
uv run pytest benchmarks -q --catalog-scope=function
uv run pytest benchmarks -q --catalog-scope=session

The comparison produces six passing items. Each timing run produces ten, with ten catalog setups for function scope and one for session scope.

Improve one group of tests in a pull request

Start with a module that repeats setup or is regularly slow in CI:

  1. Preserve its current behavioral cases and record its collected item count.
  2. Extract one named resource fixture with reliable cleanup.
  3. Group equivalent input/output cases with readable parameter IDs.
  4. Keep mutable state fresh and move shared fixtures only as high as their consumers require.
  5. Measure setup cost, then widen only the lifetime of resources whose sharing is safe.
  6. Run the affected tests alone, together, and under the CI worker configuration.

Review the resulting failure report as carefully as the source. A scalable pytest suite should let the next developer add a meaningful case, identify a broken contract, and rerun it without reconstructing the entire fixture graph.