Fixtures
Last updated on 2024-12-19 | Edit this page
Overview
Questions
- How to reuse data and objects in tests?
Objectives
- Learn how to use fixtures to store data and objects for use in tests.
Repetitiveness in tests
When writing more complex tests, you may find that you need to reuse data or objects across multiple tests.
Here is an example of a set of tests that re-use the same data a lot.
We have a class, Point
, that represents a point in 2D
space. We have a few tests that check the behaviour of the class. Notice
how we have to repeat the extact same setup code in each test.
PYTHON
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def move(self, dx, dy):
self.x += dx
self.y += dy
def reflect_over_x(self):
self.y = -self.y
def reflect_over_y(self):
self.x = -self.x
PYTHON
def test_distance_from_origin():
# Positive coordinates
point_positive_coords = Point(3, 4)
# Negative coordinates
point_negative_coords = Point(-3, -4)
# Mix of positive and negative coordinates
point_mixed_coords = Point(-3, 4)
assert point_positive_coords.distance_from_origin() == 5.0
assert point_negative_coords.distance_from_origin() == 5.0
assert point_mixed_coords.distance_from_origin() == 5.0
def test_move():
# Repeated setup again...
# Positive coordinates
point_positive_coords = Point(3, 4)
# Negative coordinates
point_negative_coords = Point(-3, -4)
# Mix of positive and negative coordinates
point_mixed_coords = Point(-3, 4)
# Test logic
point_positive_coords.move(2, -1)
point_negative_coords.move(2, -1)
point_mixed_coords.move(2, -1)
assert point_positive_coords.x == 5
assert point_positive_coords.y == 3
assert point_negative_coords.x == -1
assert point_negative_coords.y == -5
assert point_mixed_coords.x == -1
assert point_mixed_coords.y == 3
def test_reflect_over_x():
# Yet another setup repetition
# Positive coordinates
point_positive_coordinates = Point(3, 4)
# Negative coordinates
point_negative_coordinates = Point(-3, -4)
# Mix of positive and negative coordinates
point_mixed_coordinates = Point(-3, 4)
# Test logic
point_positive_coordinates.reflect_over_x()
point_negative_coordinates.reflect_over_x()
point_mixed_coordinates.reflect_over_x()
assert point_positive_coordinates.x == 3
assert point_positive_coordinates.y == -4
assert point_negative_coordinates.x == -3
assert point_negative_coordinates.y == 4
assert point_mixed_coordinates.x == -3
assert point_mixed_coordinates.y == -4
def test_reflect_over_y():
# One more time...
# Positive coordinates
point_positive_coordinates = Point(3, 4)
# Negative coordinates
point_negative_coordinates = Point(-3, -4)
# Mix of positive and negative coordinates
point_mixed_coordinates = Point(-3, 4)
# Test logic
point_positive_coordinates.reflect_over_y()
point_negative_coordinates.reflect_over_y()
point_mixed_coordinates.reflect_over_y()
assert point_positive_coordinates.x == -3
assert point_positive_coordinates.y == 4
assert point_negative_coordinates.x == 3
assert point_negative_coordinates.y == -4
assert point_mixed_coordinates.x == 3
assert point_mixed_coordinates.y == 4
Fixtures
Pytest provides a way to store data and objects for use in tests - fixtures.
Fixtures are simply functions that return a value, and can be used in tests by passing them as arguments. Pytest magically knows that any test that requires a fixture as an argument should run the fixture function first, and pass the result to the test.
Fixtures are defined using the @pytest.fixture
decorator. (Don’t worry if you are not aware of decorators, they are
just ways of flagging functions to do something special - in this case,
to let pytest know that this function is a fixture.)
Here is a very simple fixture to demonstrate this:
PYTHON
import pytest
@pytest.fixture
def my_fixture():
return "Hello, world!"
def test_my_fixture(my_fixture):
assert my_fixture == "Hello, world!"
Here, Pytest will notice that my_fixture
is a fixture
due to the @pytest.fixture
decorator, and will run
my_fixture
, then pass the result into
test_my_fixture
.
Now let’s see how we can improve the tests for the Point
class using fixtures:
PYTHON
import pytest
@pytest.fixture
def point_positive_3_4():
return Point(3, 4)
@pytest.fixture
def point_negative_3_4():
return Point(-3, -4)
@pytest.fixture
def point_mixed_3_4():
return Point(-3, 4)
def test_distance_from_origin(point_positive_3_4, point_negative_3_4, point_mixed_3_4):
assert point_positive_3_4.distance_from_origin() == 5.0
assert point_negative_3_4.distance_from_origin() == 5.0
assert point_mixed_3_4.distance_from_origin() == 5.0
def test_move(point_positive_3_4, point_negative_3_4, point_mixed_3_4):
point_positive_3_4.move(2, -1)
point_negative_3_4.move(2, -1)
point_mixed_3_4.move(2, -1)
assert point_positive_3_4.x == 5
assert point_positive_3_4.y == 3
assert point_negative_3_4.x == -1
assert point_negative_3_4.y == -5
assert point_mixed_3_4.x == -1
assert point_mixed_3_4.y == 3
def test_reflect_over_x(point_positive_3_4, point_negative_3_4, point_mixed_3_4):
point_positive_3_4.reflect_over_x()
point_negative_3_4.reflect_over_x()
point_mixed_3_4.reflect_over_x()
assert point_positive_3_4.x == 3
assert point_positive_3_4.y == -4
assert point_negative_3_4.x == -3
assert point_negative_3_4.y == 4
assert point_mixed_3_4.x == -3
assert point_mixed_3_4.y == -4
def test_reflect_over_y(point_positive_3_4, point_negative_3_4, point_mixed_3_4):
point_positive_3_4.reflect_over_y()
point_negative_3_4.reflect_over_y()
point_mixed_3_4.reflect_over_y()
assert point_positive_3_4.x == -3
assert point_positive_3_4.y == 4
assert point_negative_3_4.x == 3
assert point_negative_3_4.y == -4
assert point_mixed_3_4.x == 3
assert point_mixed_3_4.y == 4
With the setup code defined in the fixtures, the tests are more concise and it won’t take as much effort to add more tests in the future.
Challenge : Write your own fixture
In the unit testing lesson, we wrote several tests for sampling & filtering data. We turned a complex function into a properly unit tested set of functions which greatly improved the readability and maintainability of the code, however we had to repeat the same setup code in each test.
Code:
PYTHON
def sample_participants(participants: list, sample_size: int):
indexes = random.sample(range(len(participants)), sample_size)
sampled_participants = []
for i in indexes:
sampled_participants.append(participants[i])
return sampled_participants
def filter_participants_by_age(participants: list, min_age: int, max_age: int):
filtered_participants = []
for participant in participants:
if participant["age"] >= min_age and participant["age"] <= max_age:
filtered_participants.append(participant)
return filtered_participants
def filter_participants_by_height(participants: list, min_height: int, max_height: int):
filtered_participants = []
for participant in participants:
if participant["height"] >= min_height and participant["height"] <= max_height:
filtered_participants.append(participant)
return filtered_participants
def randomly_sample_and_filter_participants(
participants: list, sample_size: int, min_age: int, max_age: int, min_height: int, max_height: int
):
sampled_participants = sample_participants(participants, sample_size)
age_filtered_participants = filter_participants_by_age(sampled_participants, min_age, max_age)
height_filtered_participants = filter_participants_by_height(age_filtered_participants, min_height, max_height)
return height_filtered_participants
Tests:
PYTHON
import random
from stats import sample_participants, filter_participants_by_age, filter_participants_by_height, randomly_sample_and_filter_participants
def test_sample_participants():
# set random seed
random.seed(0)
participants = [
{"age": 25, "height": 180},
{"age": 30, "height": 170},
{"age": 35, "height": 160},
{"age": 38, "height": 165},
{"age": 40, "height": 190},
{"age": 45, "height": 200},
]
sample_size = 2
sampled_participants = sample_participants(participants, sample_size)
expected = [{"age": 38, "height": 165}, {"age": 45, "height": 200}]
assert sampled_participants == expected
def test_filter_participants_by_age():
participants = [
{"age": 25, "height": 180},
{"age": 30, "height": 170},
{"age": 35, "height": 160},
{"age": 38, "height": 165},
{"age": 40, "height": 190},
{"age": 45, "height": 200},
]
min_age = 30
max_age = 35
filtered_participants = filter_participants_by_age(participants, min_age, max_age)
expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}]
assert filtered_participants == expected
def test_filter_participants_by_height():
participants = [
{"age": 25, "height": 180},
{"age": 30, "height": 170},
{"age": 35, "height": 160},
{"age": 38, "height": 165},
{"age": 40, "height": 190},
{"age": 45, "height": 200},
]
min_height = 160
max_height = 170
filtered_participants = filter_participants_by_height(participants, min_height, max_height)
expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}, {"age": 38, "height": 165}]
assert filtered_participants == expected
def test_randomly_sample_and_filter_participants():
# set random seed
random.seed(0)
participants = [
{"age": 25, "height": 180},
{"age": 30, "height": 170},
{"age": 35, "height": 160},
{"age": 38, "height": 165},
{"age": 40, "height": 190},
{"age": 45, "height": 200},
]
sample_size = 5
min_age = 28
max_age = 42
min_height = 159
max_height = 172
filtered_participants = randomly_sample_and_filter_participants(
participants, sample_size, min_age, max_age, min_height, max_height
)
expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}]
assert filtered_participants == expected
- Try making these tests more concise by creating a fixture for the input data.
PYTHON
import pytest
@pytest.fixture
def participants():
return [
{"age": 25, "height": 180},
{"age": 30, "height": 170},
{"age": 35, "height": 160},
{"age": 38, "height": 165},
{"age": 40, "height": 190},
{"age": 45, "height": 200},
]
def test_sample_participants(participants):
# set random seed
random.seed(0)
sample_size = 2
sampled_participants = sample_participants(participants, sample_size)
expected = [{"age": 38, "height": 165}, {"age": 45, "height": 200}]
assert sampled_participants == expected
def test_filter_participants_by_age(participants):
min_age = 30
max_age = 35
filtered_participants = filter_participants_by_age(participants, min_age, max_age)
expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}]
assert filtered_participants == expected
def test_filter_participants_by_height(participants):
min_height = 160
max_height = 170
filtered_participants = filter_participants_by_height(participants, min_height, max_height)
expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}, {"age": 38, "height": 165}]
assert filtered_participants == expected
def test_randomly_sample_and_filter_participants(participants):
# set random seed
random.seed(0)
sample_size = 5
min_age = 28
max_age = 42
min_height = 159
max_height = 172
filtered_participants = randomly_sample_and_filter_participants(
participants, sample_size, min_age, max_age, min_height, max_height
)
expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}]
assert filtered_participants == expected
Fixtures also allow you to set up and tear down resources that are needed for tests, such as database connections, files, or servers, but those are more advanced topics that we won’t cover here.
Fixture organisation
Fixtures can be placed in the same file as the tests, or in a
separate file. If you have a lot of fixtures, it may be a good idea to
place them in a separate file to keep your test files clean. It is
common to place fixtures in a file called conftest.py
in
the same directory as the tests.
For example you might have this structure:
project_directory/
│
├── tests/
│ ├── conftest.py
│ ├── test_my_module.py
│ ├── test_my_other_module.py
│
├── my_module.py
├── my_other_module.py
In this case, the fixtures defined in conftest.py
can be
used in any of the test files in the tests
directory,
provided that the fixtures are imported.
Key Points
- Fixtures are useful way to store data, objects and automations to re-use them in many different tests.
- Fixtures are defined using the
@pytest.fixture
decorator. - Tests can use fixtures by passing them as arguments.
- Fixtures can be placed in a separate file or in the same file as the tests.