Skip to content

Chapter 7 · Fixtures Advanced: Scopes and conftest ​

Chapter goals: master the four scope levels — instantiation timing and shared semantics, understand conftest.py's hierarchical override mechanism plus autouse injection, and learn to trade off "shared savings" against "state isolation" correctly.

7.1 scope: How Long a Fixture Lives ​

By default every test receives a newly created fixture instance (scope="function"). When construction is expensive (a real SMTP connection, a database engine, a Docker container), raise the scope so the instance gets reused:

python
# content of conftest.py
import smtplib

import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    # created once per test module; all tests in the module share it
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
python
# content of test_module.py — both tests receive the same object
def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250


def test_noop(smtp_connection):
    resp = smtp_connection.noop()
    assert resp[0] == 250

The five official values and their destruction timing:

scopeInstantiatedTeardown runs
function (default)Before each testAfter that test ends
classBefore first test of each classAfter the class's last test
moduleBefore first test of each moduleAfter the module's last test
packageBefore first test in the packageAfter package's last test
sessionOn first request during the sessionAfter all tests finish

High Scope + Mutable State = Disaster

Once you promote scope to module/session, every test shares one mutable state. One test appends to a shared list or mutates a shared dict, and later tests fail mysteriously. Principle: high-scope fixtures should provide read-only or immutable resources (connections, config objects); anything whose state changes stays function-scoped.

7.2 conftest Hierarchy: Closest Wins ​

conftest.py files apply hierarchically: each conftest is visible only within its directory and below. When two layers define same-named fixtures, the layer closer to the test wins — enabling "defaults + local overrides":

text
tests/
├── conftest.py               # def base_url(): return "https://api.prod.example.com"
└── local/
    ├── conftest.py           # def base_url(): return "http://localhost:8000"
    └── test_local.py         # base_url resolves to the localhost version here
python
# tests/conftest.py
import pytest


@pytest.fixture
def base_url() -> str:
    """Project-wide default points at staging"""
    return "https://staging.example.com"
python
# tests/local/conftest.py
import pytest


@pytest.fixture
def base_url() -> str:
    """Tests under local/ all hit the local service — same-name override"""
    return "http://localhost:8000"

This also applies to customizing third-party plugins' fixtures — key to pytest's "convention over configuration" design.

7.3 autouse: Fixtures You Never Ask For ​

Some cross-cutting concerns (temp cwd switching, network blocking, injecting common patches) are needed by every test; listing them everywhere is tedious. autouse=True makes every test request the fixture automatically:

python
# tests/conftest.py
import os

import pytest


@pytest.fixture(autouse=True)
def _isolated_cwd(tmp_path):
    """Run every test inside a temp dir so the repo stays clean"""
    old = os.getcwd()
    os.chdir(tmp_path)          # setup: enter temp dir
    yield tmp_path
    os.chdir(old)               # teardown: restore original cwd


@pytest.fixture(autouse=True)
def _fail_on_slow(monkeypatch):
    # autouse can be side-effect only (return nothing)
    monkeypatch.setenv("APP_ENV", "testing")
python
# any test file — enjoys both environments without declaring anything
def test_writes_config():
    with open("config.yaml", "w") as f:
        f.write("debug: true")
    assert os.path.exists("config.yaml")   # written into tmp_path, repo untouched

autouse respects scope too; it may still return values — "available without being requested". Use with restraint: autouse is implicit global behavior; once numerous, nobody can describe a given test's full execution environment anymore.

7.4 Scope Conflicts: High Cannot Depend on Low ​

One hard rule: higher-scope fixtures cannot depend on lower-scope ones. A session fixture depending on a function fixture raises ScopeMismatch — a session instance is built once while its dependency would need rebuilding per test, which cannot logically hold.

Common pitfall and fix:

python
@pytest.fixture(scope="session")
def engine():
    return create_engine("sqlite:///:memory:")


@pytest.fixture(scope="session")
def tables(engine):        # session depends on session ✔
    ...

If session-level initialization truly needs per-test data, invert the structure: make the "expensive but immutable" part high-scope, and place per-test differences in lower-scope fixtures referencing it.

7.5 Decision Table ​

NeedChoose
Cheap construction, mutable state (lists/model objects)function (default)
Expensive connections, process pools, containers used read-onlymodule / session
All tests under some directory need environment/config swapOverride same-name fixture in that directory's conftest.py
Global cross-cutting behavior (cwd switch, env vars, net block)conftest + autouse=True

7.6 Chapter Summary ​

  • Five scope levels: function/class/module/package/session determine instance creation and teardown timing;
  • High scopes save time but share state — only read-only resources should be promoted;
  • Layered conftest + closest-name-wins overrides = the standard pattern for project defaults with local customization;
  • autouse=True provides declaration-free cross-cutting setup/teardown — use sparingly;
  • session/module fixtures must not depend on lower scopes (ScopeMismatch).

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. When does a scope="module" fixture run its teardown?

2. A session-scoped fixture depends on a function-scoped one. What happens?

3. tests/api/conftest.py and tests/conftest.py both define api_client. Which version do tests under tests/api/ get?

4. Which use case fits autouse best?

🛠️ Hands-On Exercises ​

  1. Change Chapter 6's db_conn fixture to module then session scope; insert data in one test and write a second verifying it can see the first test's data — feel the double-edged sword of shared state — then restore function and explain why.
  2. Define app_mode = "prod" in the project-root conftest.py, override it to "test" in some subdirectory's conftest.py, and write a test printing the value to confirm nearest-override works.
  3. Write an autouse fixture recording start/end timestamps per test and printing elapsed time in teardown; run your whole suite and watch the output.

The core mental model of fixtures is now complete. Next chapter advances into factory patterns, finalizers, and the request object.