# 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.

A pytest fixture with no `scope` argument is function-scoped: pytest runs it again for every test that requests it, and tears it down after each one. That's the right default for cheap things, and the safe one, since every test gets a fresh object.

It gets expensive when the fixture does real work: starting an application, loading a large file or a machine learning model, building a database schema, starting a container, compiling templates. A fixture that takes 300ms and is used by 400 tests costs two minutes per run, even if every test only reads what it builds.

## Find the fixtures that are rebuilt

pytest can show you when fixtures are set up and torn down:

```console
$ pytest --setup-show tests/test_api.py
tests/test_api.py
        SETUP    F app
        tests/test_api.py::test_list (fixtures used: app).
        TEARDOWN F app
        SETUP    F app
        tests/test_api.py::test_detail (fixtures used: app).
        TEARDOWN F app
```

The `F` means function scope. `pytest --durations=20` lists the slowest setup and teardown phases next to the slowest calls; a fixture that shows up in the setup of many tests is the one to look at.

## Widen the scope when tests only read it

If tests only read what the fixture builds, give it a wider scope so it's built once:

```python
# Before: loads the model for every test
@pytest.fixture
def classifier():
    return load_model("models/classifier.bin")


# After: loads it once per test session
@pytest.fixture(scope="session")
def classifier():
    return load_model("models/classifier.bin")
```

The scopes, from narrowest to widest:

| Scope | Built once per |
|---|---|
| `function` (default) | test |
| `class` | test class |
| `module` | test file |
| `package` | package (directory with `__init__.py`) |
| `session` | whole run (per worker under pytest-xdist) |

A fixture can only depend on fixtures of the same or a wider scope. A session-scoped fixture that requests `tmp_path` or `db` fails with a `ScopeMismatch` error. Use `tmp_path_factory` instead of `tmp_path` in wide-scoped fixtures:

```python
@pytest.fixture(scope="session")
def dataset_dir(tmp_path_factory):
    path = tmp_path_factory.mktemp("dataset")
    generate_dataset(path)
    return path
```

## Split it when tests modify it

If tests change what the fixture builds, a wider scope leaks those changes from one test into the next. Split the fixture in two: an expensive higher-scoped fixture that builds the thing once, and a cheap function-scoped one that resets or copies it.

```python
import copy
import json
from pathlib import Path

import pytest


@pytest.fixture(scope="session")
def _catalog_data():
    return json.loads(Path("tests/data/catalog.json").read_text())


@pytest.fixture
def catalog(_catalog_data):
    return copy.deepcopy(_catalog_data)
```

Tests keep requesting `catalog` and get a private copy, while the file is parsed once. Check that the copy is actually cheaper than building: `deepcopy` of a big object graph can be slow too.

The same pattern applies to anything with state:

- **A database**: create the schema once per session and wrap each test in a transaction that is rolled back (see [roll back per test with SQLAlchemy](https://flakytest.dev/guides/sqlalchemy-rollback-per-test/)). pytest-django and Django's `TestCase` already do this.
- **An app or client**: build the app once, and reset what tests change (dependency overrides, settings) in a function-scoped fixture.
- **A container or server**: start it once per session and clear its data per test (truncate tables, `FLUSHDB`, empty a bucket).

## Watch out for autouse

An `autouse=True` fixture runs for every test in its scope, whether the test needs it or not. A function-scoped autouse fixture in the root `conftest.py` that does real work is paid by every test in the suite. Move it into the `conftest.py` of the directory that needs it, give it a wider scope, or make it opt-in and request it by name.

## Keep the tests correct

- A wider scope shares state between tests: check that no test mutates it. Look for tests that append to lists, change attributes, or write to the shared object's files.
- Run the suite in random order with pytest-randomly to catch leaks: a test that only passes after (or before) another one depends on shared state. See [keep tests independent](https://flakytest.dev/guides/test-isolation/).
- Session scope under pytest-xdist means once per worker, not once per run. Each worker builds its own copy, which is usually what you want for isolation.
- Fixtures that hold resources (connections, files, processes) still need a teardown; with `yield` it runs at the end of the fixture's scope.

## How flakytest finds it

flakytest measures the setup time of every fixture exactly, with its scope and how many times it ran. When a function-scoped fixture ran at least 5 times, took at least 50ms on average, and adds up to at least 1 second and 3% of test time, it suggests **Fixture `name` is rebuilt for every test**, with its call count, mean and maximum time, and the tests that use it. The report's fixtures view also marks class, module and session fixtures as shared, charged to the test that triggered them.

After widening a scope, run `flakytest run` again and `flakytest compare` to check the time saved and that no test changed outcome.

## Further reading

- [pytest: fixture scopes](https://docs.pytest.org/en/stable/how-to/fixtures.html#scope-sharing-fixtures-across-classes-modules-packages-or-session)
- [pytest: dynamic scope](https://docs.pytest.org/en/stable/how-to/fixtures.html#dynamic-scope)
- [pytest-randomly](https://github.com/pytest-dev/pytest-randomly)
