Skip to main content

Fixtures: Reusable Test Setup

James counts the lines in his test file. Sixteen tests, and twelve of them start with the same three lines:

note: Note = Note(title="Test", body="Hello world", word_count=2)

He changes the Note dataclass to add a tags field, and suddenly he needs to update twelve lines of test setup. "This is the same problem I had with duplicated type definitions before dataclasses," he says.

Emma nods. "The @ symbol solved that problem. It solves this one too." She writes @pytest.fixture above a function, and James watches his twelve setup blocks collapse into one.

You learned in Chapter 51 that @dataclass is a decorator: it tells Python to transform the class below it. @pytest.fixture follows the same pattern. It tells pytest to treat the function below it as a setup function that produces test data. Any test that needs that data simply adds the fixture name as a parameter.

If you're new to programming

A fixture is a reusable piece of test setup. Instead of writing the same setup code in every test, you write it once in a fixture function. Every test that needs that setup asks for it by name. If you change the setup later, you change it in one place.

If you have testing experience from another language

pytest fixtures replace the setUp/tearDown methods from xUnit frameworks. They are more flexible: they compose, they can have different scopes, and they are injected by name rather than inherited from a base class. If you know dependency injection, fixtures are DI for tests.


Your First Fixture

A fixture is a function decorated with @pytest.fixture that returns a value. Tests receive the value by listing the fixture name as a parameter:

import pytest
from smartnotes.models import Note


@pytest.fixture
def sample_note() -> Note:
"""A basic Note for testing."""
return Note(title="Test", body="Hello", word_count=2)


def test_note_has_title(sample_note: Note) -> None:
# Arrange: handled by the fixture
# Act
result: str = sample_note.title

# Assert
assert result == "Test"


def test_note_has_body(sample_note: Note) -> None:
result: str = sample_note.body
assert result == "Hello"

How it works: when pytest sees sample_note in a test's parameter list, it calls the sample_note() fixture function and passes the return value into the test. You do not call the fixture yourself. pytest handles the wiring.

Both tests receive a fresh Note object. If test_note_has_title modified its Note, test_note_has_body would not be affected. Each test gets its own copy.


Fixtures Replace Arrange

Notice how the tests above have no Arrange phase inside the function body. The fixture IS the Arrange step. The test body contains only Act and Assert:

Without fixtureWith fixture
Arrange (3 lines) + Act + AssertAct + Assert (Arrange lives in fixture)
Duplicated in every testWritten once, shared everywhere
Change Note constructor: update 12 testsChange Note constructor: update 1 fixture

This is the same principle that led you to functions in Chapter 49: when you see the same code repeated, extract it into a named, reusable unit.


Multiple Fixtures

You can define as many fixtures as you need. Each one provides a different piece of test data:

@pytest.fixture
def sample_note() -> Note:
"""A basic Note for testing."""
return Note(title="Test", body="Hello", word_count=2)


@pytest.fixture
def long_note() -> Note:
"""A Note with a high word count."""
return Note(title="Research Paper", body="A very long body", word_count=1500)


@pytest.fixture
def empty_tags() -> list[str]:
"""An empty tag list for testing."""
return []

A test can request multiple fixtures by listing them all as parameters:

def test_note_with_no_tags(sample_note: Note, empty_tags: list[str]) -> None:
result: int = len(empty_tags)
assert result == 0
assert sample_note.title == "Test"

pytest calls both fixture functions and injects both values. The order of parameters does not matter.


Fixture Scope

By default, pytest calls a fixture function once per test that uses it. This is called function scope: each test gets a fresh value. You can change the scope so the fixture is created once for an entire file:

@pytest.fixture(scope="module")
def database_connection() -> str:
"""Simulated connection string, created once per test file."""
return "postgres://localhost/smartnotes_test"
ScopeWhen fixture runsUse case
"function" (default)Once per testMost tests; ensures isolation
"module"Once per test fileExpensive setup shared across a file

For this chapter, use the default function scope. It keeps every test fully isolated. Module scope is useful when setup is expensive (like connecting to a database), but it introduces shared state that can cause subtle bugs if you are not careful.


Fixture Composition: Fixtures Using Fixtures

A fixture can depend on another fixture. This is fixture composition:

@pytest.fixture
def sample_note() -> Note:
"""A basic Note."""
return Note(title="Test", body="Hello", word_count=2)


@pytest.fixture
def note_list(sample_note: Note) -> list[Note]:
"""A list containing one sample note."""
return [sample_note]

The note_list fixture requests sample_note as a parameter, just like a test would. pytest resolves the dependency chain: it calls sample_note() first, then passes the result into note_list().

A test that requests note_list gets a list containing a freshly created sample_note. The composition is automatic:

def test_note_list_has_one_item(note_list: list[Note]) -> None:
assert len(note_list) == 1
assert note_list[0].title == "Test"

conftest.py: Sharing Fixtures Across Files

When your test suite grows beyond one file, you need fixtures available everywhere. This is what conftest.py is for.

conftest.py is a special file name. pytest looks for files named conftest.py and automatically makes their fixtures available to every test file in the same directory (and subdirectories). You do not import it. The name is the mechanism.

tests/
├── conftest.py # Fixtures defined here are available everywhere
├── test_models.py # Can use fixtures from conftest.py
├── test_categorize.py # Can use fixtures from conftest.py

tests/conftest.py:

import pytest
from smartnotes.models import Note


@pytest.fixture
def sample_note() -> Note:
"""A basic Note for testing across all test files."""
return Note(title="Test", body="Hello", word_count=2)


@pytest.fixture
def long_note() -> Note:
"""A Note with high word count."""
return Note(title="Research", body="Long body text here", word_count=1500)

tests/test_models.py:

from smartnotes.models import Note


def test_note_title(sample_note: Note) -> None:
assert sample_note.title == "Test"

tests/test_categorize.py:

from smartnotes.models import Note


def test_long_note_category(long_note: Note) -> None:
result: str = categorize_by_count(long_note.word_count)
assert result == "long"

Neither test file imports conftest.py. Neither file imports the fixtures by name. pytest's auto-discovery handles everything. You place fixtures in conftest.py, and every test in the tests/ directory can use them.


When to Use conftest.py vs. Local Fixtures

SituationWhere to put the fixture
Fixture used by multiple test filesconftest.py
Fixture used by only one test fileIn that test file
Fixture specific to one testInline setup (no fixture needed)

Start simple. If a fixture appears in only one file, keep it there. Move it to conftest.py when a second file needs it. This avoids cluttering conftest.py with fixtures that serve a single purpose.


PRIMM-AI+ Practice: Building Fixtures

Predict [AI-FREE]

Look at this code without running it. Predict what each test will print (or whether it will pass). Write your predictions and a confidence score from 1 to 5 before checking.

import pytest
from smartnotes.models import Note


@pytest.fixture
def meeting_note() -> Note:
return Note(title="Standup", body="Quick sync", word_count=2)


@pytest.fixture
def notes_collection(meeting_note: Note) -> list[Note]:
extra: Note = Note(title="Ideas", body="Brainstorm", word_count=1)
return [meeting_note, extra]


def test_collection_length(notes_collection: list[Note]) -> None:
assert len(notes_collection) == 2


def test_first_note_title(notes_collection: list[Note]) -> None:
assert notes_collection[0].title == "Standup"


def test_meeting_note_body(meeting_note: Note) -> None:
assert meeting_note.body == "Quick sync"

Questions:

  1. How many tests will pass?
  2. Does notes_collection create one Note or two?
  3. Does test_meeting_note_body use the notes_collection fixture?
Check your predictions

All three tests pass.

  1. test_collection_length: The notes_collection fixture returns a list with two Notes, so len() returns 2.
  2. test_first_note_title: The first Note in the list came from meeting_note, which has title "Standup".
  3. test_meeting_note_body: This test requests meeting_note directly. It does NOT use notes_collection. Each fixture is independent; requesting one does not trigger another.

notes_collection creates one new Note (extra) and receives one from the meeting_note fixture. Total: two Notes in the list.

Run

Create two files:

tests/conftest.py:

import pytest
from smartnotes.models import Note


@pytest.fixture
def sample_note() -> Note:
return Note(title="Test", body="Hello", word_count=2)

tests/test_fixtures_practice.py:

from smartnotes.models import Note


def test_note_title(sample_note: Note) -> None:
assert sample_note.title == "Test"


def test_note_word_count(sample_note: Note) -> None:
assert sample_note.word_count == 2

Run uv run pytest tests/test_fixtures_practice.py -v. Both tests should pass. Notice that neither file imports conftest.py.

Investigate

Add a print(f"Fixture called for: {sample_note.title}") line inside the sample_note fixture. Run pytest with uv run pytest -s (the -s flag shows print output). How many times does the print appear? Once per test, because function scope creates a fresh fixture for each test.

Modify

Add a second fixture called long_note to conftest.py that returns a Note with word_count=1500. Write a test in test_fixtures_practice.py that uses long_note. Predict whether it passes before running.

Make [Mastery Gate]

Without looking at any examples, create:

  1. A conftest.py with two fixtures: sample_note (short note) and long_note (1500 words).
  2. A test_mastery.py with four tests:
    • Two tests using sample_note (check title and word count)
    • Two tests using long_note (check title and word count)

All four tests must pass. No test file should import conftest.py. Run uv run pytest -v and confirm.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Generate a conftest.py

I have a SmartNotes project with a Note dataclass:
Note(title: str, body: str, word_count: int)

Write a conftest.py file with three pytest fixtures:
1. sample_note: a basic Note
2. long_note: a Note with word_count above 1000
3. note_list: a list containing both notes (use fixture composition)

Type-annotate all variables and return types.

Review the AI's output. Check: does note_list take the other two fixtures as parameters (composition)? Are return types annotated? Does it use @pytest.fixture on each function?

What you're learning: You are verifying that AI-generated fixtures follow composition patterns correctly.

Prompt 2: Diagnose a Fixture Problem

This test fails with "fixture 'sample_note' not found". Why?

# tests/test_models.py
from smartnotes.models import Note

def test_title(sample_note: Note) -> None:
assert sample_note.title == "Test"

# tests/helpers/conftest.py
import pytest
from smartnotes.models import Note

@pytest.fixture
def sample_note() -> Note:
return Note(title="Test", body="Hello", word_count=2)

Read the AI's diagnosis. It should identify that conftest.py is in a subdirectory (tests/helpers/) while the test is in tests/. pytest's auto-discovery only makes fixtures available in the same directory and below, not in sibling directories.

What you're learning: You are using the AI to diagnose fixture discovery issues, which reinforces how conftest.py auto-discovery actually works.


Key Takeaways

  1. @pytest.fixture turns a function into reusable test setup. Tests receive the fixture's return value by listing the fixture name as a parameter. No manual calls needed.

  2. Fixtures replace the Arrange phase. Your test body shrinks to Act and Assert. Setup lives in one place, updated once.

  3. Fixtures compose: a fixture can depend on another fixture. pytest resolves the dependency chain automatically.

  4. conftest.py shares fixtures across test files without imports. pytest looks for this file by name and makes its fixtures available to every test in the same directory. You do not import it. The name is the mechanism.

  5. Default scope is function: each test gets a fresh fixture. This preserves test isolation. Use module scope only when setup is expensive and you understand the shared-state trade-offs.


Looking Ahead

Fixtures eliminate duplicated setup. But you still have a different kind of duplication: eight test functions that all do the same thing with different inputs. In Lesson 3, you will learn @pytest.mark.parametrize, which collapses those eight functions into one.