How to Use Fixtures in pytest
Requesting Fixtures
At a fundamental level, test functions request their required dependencies by declaring them directly as function parameters. When pytest executes a test case, it inspects the function signature, locates registered fixtures that match those parameter names, runs the fixture functions, captures their returned values, and injects them as arguments into the test.
Basic Example
Consider the following demonstration using a simplified task-processing workflow:
import pytest
class Task:
def __init__(self, title: str):
self.title = title
self.is_complete = False
def complete(self):
self.is_complete = True
class Workflow:
def __init__(self, *tasks: Task):
self.items = list(tasks)
self._process_tasks()
def _process_tasks(self):
for item in self.items:
item.complete()
# Setup
@pytest.fixture
def initial_tasks():
return [Task("Setup"), Task("Cleanup")]
def test_workflow_execution(initial_tasks):
# Act
workflow = Workflow(*initial_tasks)
# Assert
assert all(t.is_complete for t in workflow.items)
In this scenario, two classes are defined: Task and Workflow.
- The
Taskclass represents a single unit of work, tracking its title and completion status. It provides acomplete()method to update its state. - The
Workflowclass accepts multipleTaskinstances during initialization. It automatically triggers the processing step, which marks every provided task as complete.
A fixture named initial_tasks prepares a default list of Task objects. The test function test_workflow_execution reqeusts this fixture. When pytest runs the test, it detects the dependency, executes initial_tasks, and passes the resulting list directly into the test. The test then instantiates the Workflow and verifeis that all tasks transitioned to the completed state.
If executed manually, the underlying flow would resemble:
def setup_initial_tasks():
return [Task("Setup"), Task("Cleanup")]
def test_workflow_execution(initial_tasks):
# Act
workflow = Workflow(*initial_tasks)
# Assert
assert all(t.is_complete for t in workflow.items)
# Setup equivalent
tasks = setup_initial_tasks()
test_workflow_execution(initial_tasks=tasks)
Fixtures Depending on Other Fixtures
One of pytest's strongest features is its highly flexible fixture dependency system. Complex setup logic can be broken down into smaller, focused functions where each explicitly states its own requirements. Fixtures can request other fixtures using the exact same parameter-declaration pattern used by tests.
# contents of test_workflow.py
import pytest
# Setup
@pytest.fixture
def seed_value():
return 10
# Setup
@pytest.fixture
def number_list(seed_value):
return [seed_value]
def test_list_expansion(number_list):
# Act
number_list.append(25)
# Assert
assert number_list == [10, 25]
This structure mirrors the previous example. Here, number_list depends on seed_value. When the test requests number_list, pytest resolves the dependency graph: it runs seed_value first, injects its output into number_list, and finally provides the fully constructed list to the test. The request rules for fixtures are identical to those for tests.
Manually, this execution chain translates to:
def provide_seed():
return 10
def build_list(seed_val):
return [seed_val]
def test_list_expansion(prepared_list):
# Act
prepared_list.append(25)
# Assert
assert prepared_list == [10, 25]
# Manual resolution
seed = provide_seed()
collection = build_list(seed_val=seed)
test_list_expansion(prepared_list=collection)
Reusability and Test Isolation
The fixture system shines when defining reusable setup routines that can be shared across multiple tests without introducing state leakage. Each test receives a fresh, independent instance of the requested fixture, guaranteeing that modifications made in one test do not affect another. This ensures consistent and repeatable outcomes across your test suite.
# contents of test_workflow.py
import pytest
# Setup
@pytest.fixture
def seed_value():
return 10
# Setup
@pytest.fixture
def number_list(seed_value):
return [seed_value]
def test_append_integer(number_list):
# Act
number_list.append(42)
# Assert
assert number_list == [10, 42]
def test_append_float(number_list):
# Act
number_list.append(3.14)
# Assert
assert number_list == [10, 3.14]
Here, both test_append_integer and test_append_float declare number_list as an argument. Behind the scenes, pytest invokes the fixture factory separately for each test. The seed_value fixture also runs twice. This ensures that both tests start from a clean slate, eliminating cross-test pollution.
If handled manually without an automated framework, the necessary repetition would look like this:
def provide_seed():
return 10
def build_list(seed_val):
return [seed_val]
def test_append_integer(prepared_list):
# Act
prepared_list.append(42)
# Assert
assert prepared_list == [10, 42]
def test_append_float(prepared_list):
# Act
prepared_list.append(3.14)
# Assert
assert prepared_list == [10, 3.14]
# Manual execution for Test 1
s1 = provide_seed()
l1 = build_list(seed_val=s1)
test_append_integer(prepared_list=l1)
# Manual execution for Test 2 (requires full re-setup)
s2 = provide_seed()
l2 = build_list(seed_val=s2)
test_append_float(prepared_list=l2)