Writing good tests

pytest fixtures best practices

How to write pytest fixtures that stay fast and readable: scopes, yield teardown, factories as fixtures, conftest.py placement, autouse, parametrize, tmp_path and monkeypatch.

Fixtures are pytest's best feature and the most common reason a pytest suite becomes slow or hard to follow. A good fixture does one thing, says what it does in its name, cleans up after itself, and lives as close to the tests that use it as possible. A bad one does ten things for every test, whether the test needs them or not.

Choose the scope deliberately

A fixture's scope decides how often it runs:

Scope Runs once per Good for
function (default) test Anything a test modifies
class test class Data shared by the tests of one class
module test file Expensive read-only data for one file
package test directory Rarely needed
session run (per pytest-xdist worker) Engines, containers, browsers, schema

Function scope is the safe default: every test gets a fresh copy, so tests can't affect each other. It becomes a problem when the fixture is expensive. A fixture that takes 200 ms and is used by 500 tests costs 100 seconds per run.

The fix is usually a split: an expensive, wide-scoped fixture that builds the thing once, and a cheap function-scoped one that hands each test a clean view of it:

import pytest
from testcontainers.redis import RedisContainer


@pytest.fixture(scope="session")
def redis_container():
    with RedisContainer("redis:7") as container:
        yield container


@pytest.fixture
def redis_client(redis_container):
    client = redis_container.get_client()
    yield client
    client.flushdb()
    client.close()

The container starts once; every test gets an empty database. The same shape works for database schemas (create once, roll back per test, as in SQLAlchemy rollback per test) and browsers (share the browser between tests). Expensive function-scoped pytest fixtures goes into how to find these and widen them safely.

A fixture can only use fixtures of the same or a wider scope: a session fixture can't request a function-scoped one.

Clean up with yield

Put teardown after a yield. pytest runs it when the fixture goes out of scope, even if the test failed:

import pytest

from myapp.server import start_server


@pytest.fixture(scope="session")
def server():
    process = start_server(port=0)
    yield process
    process.terminate()
    process.wait(timeout=10)

If the code before yield raises, the code after it doesn't run, so set up one resource per fixture. With two resources in one fixture, a failure starting the second leaks the first. Two fixtures, one depending on the other, each clean up their own.

request.addfinalizer(fn) does the same job; yield is easier to read and is what the pytest docs recommend.

Return factories, not objects

When tests need slightly different data, a fixture that returns one fixed object forces every variation into its own fixture. Return a function instead (the "factory as fixture" pattern from the pytest docs):

import pytest

from myapp.models import Customer


@pytest.fixture
def make_customer(db_session):
    def make(name="Alice", plan="free", **fields):
        customer = Customer(name=name, plan=plan, **fields)
        db_session.add(customer)
        db_session.flush()
        return customer

    return make


def test_paid_customer_gets_invoice(make_customer):
    customer = make_customer(plan="pro")
    assert customer.needs_invoice()


def test_two_customers_with_the_same_name(make_customer):
    first = make_customer()
    second = make_customer()
    assert first.id != second.id

Each test states exactly the data it depends on, and new variations need no new fixtures. If the factory creates things the database rollback doesn't undo (files, objects in an external service), record them in a list and delete them after a yield. factory_boy and model_bakery are this pattern packaged; faster test factories covers keeping them cheap.

Put fixtures in the right conftest.py

A conftest.py makes its fixtures available to every test in its directory and below. Put each fixture in the lowest directory that contains all its users:

tests/
  conftest.py          # engine, session, settings used everywhere
  api/
    conftest.py        # api_client, auth headers
    test_orders.py
  billing/
    conftest.py        # stripe stubs only billing needs
    test_invoices.py

A fixture used by one file belongs in that file. A root conftest.py with hundreds of fixtures is a sign that nobody knows which ones are still used. A fixture with the same name in a lower conftest.py or test module overrides the higher one, which is useful for specializing (settings for the API tests) but confusing when it's accidental.

pytest --fixtures-per-test tests/api/test_orders.py shows which fixtures each test uses and where they're defined. pytest --setup-show prints every fixture setup and teardown as it happens, with its scope.

Use autouse sparingly

autouse=True runs a fixture for every test in its scope without the test asking for it. That's right for a short list of things every test must have:

  • blocking real network access (block network calls)
  • clearing an in-memory cache or a registry between tests
  • resetting a global that the app mutates

It's wrong for anything expensive or anything only some tests need. An autouse fixture that creates a user and logs in costs every test in the directory, including the ones that never touch a user, and nobody reading a test can see that it happens. Ask for fixtures by name, or with @pytest.mark.usefixtures("name") on a class.

Parametrize fixtures for variants

To run the same tests against several implementations or configurations, parametrize the fixture rather than each test:

import pytest

from myapp.storage import LocalStorage, MemoryStorage


@pytest.fixture(params=["memory", "local"])
def storage(request, tmp_path):
    if request.param == "memory":
        return MemoryStorage()
    return LocalStorage(root=tmp_path)


def test_roundtrip(storage):
    storage.write("a.txt", b"hello")
    assert storage.read("a.txt") == b"hello"

Every test that uses storage runs once per parameter, with ids like test_roundtrip[memory]. To choose the parameter from the test instead, use @pytest.mark.parametrize("storage", [...], indirect=True), which passes each value to the fixture as request.param.

Use the built-in fixtures

pytest's built-ins are isolated and undo themselves. Prefer them to hand-rolled versions:

  • tmp_path gives each test its own empty directory (a pathlib.Path). Never write to the working directory or a fixed path like /tmp/output.json: parallel tests overwrite each other and leftovers change the next run. tmp_path_factory is the session-scoped version.
  • monkeypatch changes attributes, dictionary items, environment variables and the working directory, and restores them after the test:
def test_sandbox_mode(monkeypatch):
    monkeypatch.setenv("PAYMENTS_MODE", "sandbox")
    monkeypatch.delenv("STRIPE_KEY", raising=False)
    monkeypatch.setattr("myapp.payments.charge", lambda amount: "ch_test")
    ...
  • capsys and caplog capture output and log records without touching global handlers.

monkeypatch is function-scoped. For a wider scope, use pytest.MonkeyPatch.context() inside a session or module fixture, so the undo still happens.

Avoid hidden global state

A fixture that changes something global without restoring it makes tests depend on the order they run in. Common offenders:

  • assigning to a module attribute (config.FEATURE_X = True) instead of monkeypatch.setattr
  • os.environ["KEY"] = ... instead of monkeypatch.setenv
  • registering signal handlers, plugins or URL routes and never removing them
  • caching with functools.lru_cache and never calling cache_clear()
  • a session fixture that returns a mutable object (a dict, a list, an ORM instance) that tests then modify

If tests modify a shared object, either give them a fresh copy from a function-scoped fixture or don't share it. Run the suite in random order with pytest-randomly to find leaks: keep tests independent explains how.

Don't make fixtures do too much

A fixture named setup_data that creates an organization, three users, a project, twenty tasks and a Stripe customer is convenient for the first test and a tax on every later one: each test pays for all of it, and when it fails nobody knows which part mattered. Signs a fixture does too much:

  • its name describes when it runs (setup, base_data) rather than what it returns
  • most tests that use it only read one of the things it creates
  • it's slow in the profile

Split it into small fixtures that depend on each other (org, user(org), project(org)) and let each test request only what it uses. pytest builds the dependency graph and creates each fixture once per scope, however many other fixtures need it.

See what your fixtures cost

pytest's --durations reports setup time per test but not per fixture. flakytest run measures every fixture's setup exactly, shows the totals per fixture in the report, and suggests Fixture name is rebuilt for every test when a function-scoped fixture runs at least 5 times, averages 50 ms or more and adds up to a noticeable share of the run.

Further reading

Related guides

  • Expensive function-scoped pytest fixtures: A pytest fixture without a scope runs again for every test that uses it. Give expensive fixtures module or session scope, or split them into a shared part and a cheap reset.
  • Keep tests independent of each other: Tests should pass alone, in any order and in parallel. Find order-dependent pytest tests with pytest-randomly and pytest-xdist, and fix the shared state behind them.
  • Faster factory_boy and model_bakery factories: Slow factory_boy and model_bakery factories? Create shared data once, use build() or baker.prepare() instead of saving, bulk create rows, reuse one Faker instance and tune Hypothesis.
  • SQLAlchemy testing best practices: How to test SQLAlchemy 2.0 code with pytest: schema once per session, savepoint rollback per test, factory_boy factories, statement counting, AsyncSession and Alembic checks.

Updated October 18, 2026 · Suggest a change