Skip to main content
Pytest beginner Lesson 2 of 10

Pytest Fixtures

Replace repeated setup with fixtures, control how often they run with scope, and watch teardown happen after a test fails.

Every test needs something set up: a temp file, a client, a database row. A fixture is a function that provides it, requested by naming it as a test parameter.

The repetition to remove

# test_orders.py
import sqlite3


def test_insert_order():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    conn.execute("INSERT INTO orders (total) VALUES (25.50)")
    assert conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1
    conn.close()


def test_sum_orders():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    conn.execute("INSERT INTO orders (total) VALUES (25.50)")
    conn.execute("INSERT INTO orders (total) VALUES (10.00)")
    assert conn.execute("SELECT SUM(total) FROM orders").fetchone()[0] == 35.50
    conn.close()
test_orders.py ..                                               [100%]
2 passed in 0.01s

It works and it is unmaintainable. Change the schema and you edit every test.

The fixture

import sqlite3
import pytest


@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    yield conn                    # the test runs here
    conn.close()                  # teardown, after the test


def test_insert_order(db):
    db.execute("INSERT INTO orders (total) VALUES (25.50)")
    assert db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1


def test_sum_orders(db):
    db.execute("INSERT INTO orders (total) VALUES (25.50)")
    db.execute("INSERT INTO orders (total) VALUES (10.00)")
    assert db.execute("SELECT SUM(total) FROM orders").fetchone()[0] == 35.50
test_orders.py ..                                               [100%]
2 passed in 0.01s

Naming db as a parameter is the whole request mechanism. pytest matches the parameter name to a fixture, calls it, and passes the yielded value.

Watching it run

Make the lifecycle visible:

@pytest.fixture
def db():
    print("\n  SETUP: creating database")
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    yield conn
    print("  TEARDOWN: closing database")
    conn.close()
pytest -s -v
test_orders.py::test_insert_order
  SETUP: creating database
PASSED  TEARDOWN: closing database

test_orders.py::test_sum_orders
  SETUP: creating database
PASSED  TEARDOWN: closing database

2 passed in 0.02s

Setup and teardown ran twice — once per test. That is the default function scope, and it is what stops one test’s rows leaking into another’s assertions.

Teardown runs after a failure too

def test_deliberately_fails(db):
    db.execute("INSERT INTO orders (total) VALUES (1.00)")
    assert False, "forced failure"
test_orders.py::test_deliberately_fails
  SETUP: creating database
FAILED  TEARDOWN: closing database

E       AssertionError: forced failure

TEARDOWN still printed. Everything after yield runs whether the test passed, failed or raised — which is why yield is better than a return plus manual cleanup at the end of each test.

Scope

@pytest.fixture(scope="session")
def expensive_resource():
    print("\n  SETUP: starting container (slow)")
    yield {"host": "localhost", "port": 5432}
    print("\n  TEARDOWN: stopping container")


@pytest.fixture(scope="function")
def order_id():
    return 42
test_scopes.py::test_one
  SETUP: starting container (slow)
PASSED
test_scopes.py::test_two PASSED
test_scopes.py::test_three PASSED
  TEARDOWN: stopping container

3 passed in 0.03s

Setup once, teardown once, three tests. The five scopes:

ScopeRuns once per
function (default)test
classtest class
moduletest file
packagepackage directory
sessionwhole pytest run

The trade is isolation against speed. A session-scoped database is fast and lets one test corrupt the next. Use wide scopes for read-only or self-resetting resources — a running container, a loaded model, a parsed config — and keep mutable state at function.

Here is the failure mode, made concrete:

@pytest.fixture(scope="module")
def shared_db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    yield conn
    conn.close()


def test_a_inserts(shared_db):
    shared_db.execute("INSERT INTO orders (total) VALUES (25.50)")
    assert shared_db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1


def test_b_expects_empty(shared_db):
    assert shared_db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 0
test_orders.py::test_a_inserts PASSED                            [ 50%]
test_orders.py::test_b_expects_empty FAILED                      [100%]

E       assert 1 == 0

test_b fails because of what test_a did. Worse, run it alone and it passes:

pytest test_orders.py::test_b_expects_empty
1 passed in 0.01s

A test that passes alone and fails in the suite is the signature of shared mutable state.

Fixtures using fixtures

@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL)")
    yield conn
    conn.close()


@pytest.fixture
def db_with_orders(db):                # requests db, then adds to it
    rows = [("alice", 25.50), ("bob", 10.00), ("alice", 99.99)]
    db.executemany("INSERT INTO orders (customer, total) VALUES (?, ?)", rows)
    return db


def test_alice_total(db_with_orders):
    total = db_with_orders.execute(
        "SELECT SUM(total) FROM orders WHERE customer = 'alice'"
    ).fetchone()[0]
    assert total == pytest.approx(125.49)
test_orders.py::test_alice_total PASSED                          [100%]

Fixtures compose by requesting each other, so you can build a small ladder — empty database, seeded database, database with an authenticated client — without repetition.

Sharing with conftest.py

Move db into conftest.py and every test in that directory and below can request it, with no import:

# conftest.py
import sqlite3
import pytest


@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL)")
    yield conn
    conn.close()
tests/
├── conftest.py            # db available to everything below
├── test_orders.py
└── api/
    ├── conftest.py        # extra fixtures, just for api/
    └── test_endpoints.py

The nearest conftest.py wins, so api/conftest.py can override db for those tests only.

See what is actually available:

pytest --fixtures -q
db
    Fresh in-memory SQLite with the orders schema.
db_with_orders
    db, seeded with three orders across two customers.
tmp_path
    Return a temporary directory path object which is unique to each test function.
capsys
    Enable text capturing of writes to sys.stdout and sys.stderr.
monkeypatch
    A convenient fixture for monkey-patching.

Your fixtures and the built-in ones, together. When a fixture “is not found”, run this first — the name is usually not what you thought.

Built-in fixtures worth knowing

def test_writes_a_file(tmp_path):
    target = tmp_path / "report.csv"
    target.write_text("id,total\n1,25.50\n")

    assert target.exists()
    assert target.read_text().splitlines()[1] == "1,25.50"
    print(f"\n  used {tmp_path}")
  used /tmp/pytest-of-you/pytest-14/test_writes_a_file0
PASSED

A fresh directory per test, cleaned up automatically. Never write test files into the repo.

def test_captures_output(capsys):
    print("processing 3 orders")
    captured = capsys.readouterr()
    assert "3 orders" in captured.out


def test_patches_environment(monkeypatch):
    monkeypatch.setenv("API_TOKEN", "test-token-123")
    import os
    assert os.environ["API_TOKEN"] == "test-token-123"
test_builtins.py::test_captures_output PASSED                    [ 50%]
test_builtins.py::test_patches_environment PASSED                [100%]

monkeypatch undoes every change it made when the test ends, so an environment variable or patched attribute cannot leak into the next test.

Autouse

@pytest.fixture(autouse=True)
def reset_registry():
    print("\n  clearing registry")
    yield
test_autouse.py::test_one
  clearing registry
PASSED
test_autouse.py::test_two
  clearing registry
PASSED

Runs for every test without being requested. Convenient for global reset, and easy to abuse — an autouse fixture is invisible at the call site, so a reader cannot tell why a test behaves as it does. Reserve it for cleanup that genuinely applies to everything.

Practice

1. Write a fixture that yields a temp CSV and confirm the file is gone after the test.
@pytest.fixture
def csv_file(tmp_path):
    p = tmp_path / "data.csv"
    p.write_text("a,b\n1,2\n")
    yield p
    print(f"\n  exists during teardown: {p.exists()}")
  exists during teardown: True
PASSED

Still there at teardown — tmp_path is cleaned by pytest after all teardown finishes, and it actually keeps the last few runs on disk for debugging. Look under /tmp/pytest-of-<user>/.

2. Make a fixture module-scoped, mutate it in one test, and assert the original state in another.
test_a_inserts PASSED
test_b_expects_empty FAILED — assert 1 == 0

And test_b passes when run alone. Order-dependent failures are the standard cost of a wide scope on mutable state — covered further in the flaky-tests lesson.

3. Request a fixture that does not exist.
E       fixture 'databse' not found
>       available fixtures: cache, capfd, capsys, db, db_with_orders, doctest_namespace,
        monkeypatch, pytestconfig, record_property, recwarn, tmp_path, tmp_path_factory ...

pytest lists everything available, so a typo is obvious. Note it does not suggest the near match — read the list.

4. Add an autouse fixture that prints, then run a test that requests nothing.

It still runs. That is the point and the danger: nothing at the test’s call site indicates it happened. If a test’s behaviour depends on an autouse fixture, request it explicitly instead so the dependency is visible.

Next: running one test against many inputs without duplicating it.

Frequently Asked Questions

What is conftest.py?
A file pytest imports automatically, making its fixtures available to every test in that directory and below, with no import statement. Nested conftest.py files are allowed and the nearest one wins, which is how a subdirectory overrides a shared fixture.
When does a fixture's teardown run?
Everything after the yield runs once the test finishes, whether it passed, failed, or raised. That is why yield is preferable to a plain return with manual cleanup — a failing test still releases its resources.
What scope should I use?
function by default, so each test gets a fresh object and cannot be polluted by another. Widen to module or session only for genuinely expensive setup like a database container, and then make sure the fixture is read-only or reset between uses.
Why is my fixture not found?
Either the name is misspelled in the test's parameter list, or the fixture lives in a module the test cannot see. Fixtures are only visible from the same file or from a conftest.py at or above the test's directory. Run pytest --fixtures to list what is actually available.