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.
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.
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 fixture | With fixture |
|---|---|
| Arrange (3 lines) + Act + Assert | Act + Assert (Arrange lives in fixture) |
| Duplicated in every test | Written once, shared everywhere |
| Change Note constructor: update 12 tests | Change 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"
| Scope | When fixture runs | Use case |
|---|---|---|
"function" (default) | Once per test | Most tests; ensures isolation |
"module" | Once per test file | Expensive 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
| Situation | Where to put the fixture |
|---|---|
| Fixture used by multiple test files | conftest.py |
| Fixture used by only one test file | In that test file |
| Fixture specific to one test | Inline 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:
- How many tests will pass?
- Does
notes_collectioncreate one Note or two? - Does
test_meeting_note_bodyuse thenotes_collectionfixture?
Check your predictions
All three tests pass.
test_collection_length: Thenotes_collectionfixture returns a list with two Notes, solen()returns 2.test_first_note_title: The first Note in the list came frommeeting_note, which has title"Standup".test_meeting_note_body: This test requestsmeeting_notedirectly. It does NOT usenotes_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:
- A
conftest.pywith two fixtures:sample_note(short note) andlong_note(1500 words). - A
test_mastery.pywith four tests:- Two tests using
sample_note(check title and word count) - Two tests using
long_note(check title and word count)
- Two tests using
All four tests must pass. No test file should import conftest.py. Run uv run pytest -v and confirm.
Try With AI
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
-
@pytest.fixtureturns 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. -
Fixtures replace the Arrange phase. Your test body shrinks to Act and Assert. Setup lives in one place, updated once.
-
Fixtures compose: a fixture can depend on another fixture. pytest resolves the dependency chain automatically.
-
conftest.pyshares 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. -
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.