How Pytest Fixtures Can Help You Write More Readable And Efficient Tests
Do you find yourself with lots of boilerplate code when writing unit tests?
Monotonous code like database setup, teardown, API clients or test data can be painful to replicate across 10s or 100s of unit tests.
When writing tests, it’s often necessary to set up some initial state before running the actual test code.
This setup can be time-consuming to write, especially when there are multiple tests that require the same steps.
Tests should be simple to understand, refactor, extend and maintain throughout the lifecycle of your project.
Fixtures in Pytest solve some of the problems of code duplication and boilerplate.
They help you define reusable setup or teardown code that can be used across multiple tests.
Instead of duplicating the same setup in every test, a fixture can be defined once and used in multiple tests.
This not only reduces duplication but also makes it easier to maintain, as any changes only need to be made in one place.
In this article, you’ll learn more about Pytest fixtures, their benefits and how they can help you write better and simpler unit tests.
Let’s get started then?
Objectives
By the end of this tutorial you should be able to:
- Define what are Pytest fixtures.
- Understand the benefits of Pytest fixtures
- Use fixtures in your Unit Tests.
- Understand fixture scopes and parameterised fixtures
- Write effective, easier-to-maintain unit tests that leverage fixtures
- Build a simple calculator API using Flask and test it using Pytest fixtures
What Are Pytest Fixtures
Before we dive into applying fixtures, let’s take a quick look at what fixtures actually are.
Fixtures are methods in Pytest that provide a fixed baseline for tests to run on top of.
A fixture can be used to set up preconditions for a test, provide data, or perform a teardown after a test is finished.
They are defined using the @pytest.fixture
decorator in Python and can be passed to test functions as arguments.
Fixtures greatly simplify the process of writing tests by allowing you to reuse code across multiple tests and providing a consistent starting point for each test.
Pytest has several built-in fixtures for common use cases, such as setting up a test database, mocking external dependencies, and setting up test data.
Fixtures also have various scopes, such as function scope, class scope, module scope, and session scope.
The fixture scope defines how long a fixture will be available during the test session.
This allows you to control the lifetime of fixtures and choose the appropriate scope for the fixture based on your testing needs. We’ll talk more about scopes later in this article.
Overall, fixtures are a powerful feature of Pytest and help reduce code duplication, improve test reliability, and make tests more modular and maintainable.
How To Use PyTest Fixtures
Now let’s get into the meat of the article. How to use fixtures, by creating a real simple application.
To understand fixtures, we’re going to build a basic calculator app and serve it using a Flask API.
If you’re not familiar with Flask, don’t worry, you can skip the API and its testing bit and only focus on the core logic (Calculator).
Project Set Up
The project has the following structure
Getting Started
To get started, clone the repo here, or you can create your own repo by creating a folder and running git init
to initialise it.
Prerequisites
In this project, we’ll be using Python 3.10.10.
Create a virtual environment and install the requirements (packages) using1
pip install -r requirements.txt
Source Code
The source code for this example is a Flask API that includes a Basic Calculator app and computes the sum, difference, multiplication and division of 2 numbers.
Calculator
/calculator/core.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42class Calculator:
def __init__(self, a: int | float = None, b: int | float = None) -> None:
self.a = a
self.b = b
def add(self) -> int | float:
"""
Add two numbers
return: sum of two numbers
"""
return self.a + self.b
def subtract(self) -> int | float:
"""
Subtract two numbers
return: difference of two numbers
"""
return self.a - self.b
def multiply(self) -> int | float:
"""
Multiply two numbers
return: product of two numbers
"""
return self.a * self.b
def divide(self) -> int | float:
"""
Divide two numbers
return: quotient of two numbers
"""
if self.b != 0:
return self.a / self.b
else:
raise ZeroDivisionError("Cannot divide by zero")
def square(self) -> int | float:
"""
Square a number
return: square of a number
"""
return self.a**2
Above we have a very simple Calculator
class that performs basic calculations.
Flask App
Thea Flask app that provides an API wrapper over the calculator allowing us to send remote requests to the server and get calculated results.
The Flask app looks like this.
/app/app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52from flask import Flask, jsonify, request
from calculator.core import Calculator
# Create the Flask application
app = Flask(__name__)
# Create the routes
def index():
return ‘Index Page’
# Add a route for the add function
def add():
data = request.get_json()
a = data[‘a’]
b = data[‘b’]
result = Calculator(a, b).add()
return jsonify(result)
# Add a route for the subtract function
def subtract():
data = request.get_json()
a = data[‘a’]
b = data[‘b’]
result = Calculator(a, b).subtract()
return jsonify(result)
# Add a route for the multiply function
def multiply():
data = request.get_json()
a = data[‘a’]
b = data[‘b’]
result = Calculator(a, b).multiply()
return jsonify(result)
# Add a route for the divide function
def divide():
data = request.get_json()
a = data[‘a’]
b = data[‘b’]
if b == 0:
return jsonify(“Cannot divide by zero”), 400
result = Calculator(a, b).divide()
return jsonify(result)
if __name__ == ‘__main__’:
app.run()
We have 4 simple routes (one for each operation), each taking a POST request containing 2 values a
and b
in the payload.
This app calls the above Calculator class to perform the computation.
Unit Tests
The unit tests are defined in 2 separate files
test_calculator_class.py
— Tests the Calculator Classtest_calculator_api.py
— Tests the API Endpoints with custom payload
Let’s take a look at each of these and how they make use of Pytest fixtures.
Fixtures In The Test
The easiest way to define fixtures, is within the test itself. Let’s see how we can test our code using fixtures defined within the test.
test_calculator_class.py
1
2
3
4
5
6import pytest
from calculator.core import Calculator
def calculator():
return Calculator(2, 3)
Basic Calculator Testing with Predefined Fixture
1 | def test_add(calculator): |
Here we’ve defined the Pytest fixture using the @pytest.fixture
decorator.
We initialised the Calculator
class with the values (2,3) and returned an instance of the class.
We then pass this fixture to each of the unit tests thus eliminating the need to re-initialise the Calculator
class in every test.
Let’s look at how we can do this for the API too.
test_calculator_api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43import pytest
from app.app import app
def client():
with app.test_client() as client:
yield client
def json_headers():
return {"Content-Type": "application/json"}
def test_add(client, json_headers, json_data):
response = client.post("/api/add/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == 3
def test_subtract(client, json_headers, json_data):
response = client.post("/api/subtract/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == -1
def test_multiply(client, json_headers, json_data):
response = client.post("/api/multiply/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == 2
def test_divide(client, json_headers, json_data):
response = client.post("/api/divide/", headers=json_headers, json=json_data)
assert response.status_code == 200
assert response.json == 0.5
def test_divide_by_zero(client, json_headers):
response = client.post("/api/divide/", headers=json_headers, json={"a": 1, "b": 0})
assert response.status_code == 400
assert response.json == "Cannot divide by zero"
In this test, we define 2 fixtures
- The Flask Client
- JSON headers for the API request
If you’re unfamiliar with APIs and Flask I recommend you read up on the basics to understand better.
The above fixtures allow us to define the Client and JSON headers once, and reuse them when making POST
requests in our tests.
Fixtures Across Multiple Tests via Conftest
A more efficient way is to stick common fixtures in a file called conftest.py
where all unit test files will pick them up automatically.
If you’re unfamiliar with conftest
, this article on Pytest conftest will give you a solid base.
conftest.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32import pytest
from calculator.core import Calculator
from app.app import app
def calculator():
return Calculator(2, 3)
def custom_calculator(scope="module"):
def _calculator(a, b):
return Calculator(a, b)
return _calculator
def client():
with app.test_client() as client:
yield client
def json_headers():
return {"Content-Type": "application/json"}
def json_data():
return {"a": 1, "b": 2}
Here we define our fixtures and can easily use them within our Unit Tests.
We have various fixtures,
- Calculator fixture
- Custom calculator fixture (Parameterized fixture)
- Flask client fixture
- JSON headers fixture
- JSON data fixture
Parameterized Fixtures
These are fixtures that can accept one or more arguments and be initialised at run time.
In the code sample from the previous block, we defined the custom_calculator
fixture that allows us to pass different values of (a, b) within the tests.
You can define parameterized fixtures by defining a fixture within a fixture.
For e.g.1
2
3
4
5
6
def custom_calculator(scope="module"):
def _calculator(a, b):
return Calculator(a, b)
return _calculator
This superpower allows us to initialise our Calculator
class with custom values for each test. Very handy.
Fixture Dependency Injection
Fixtures can also be called (or requested) by other fixtures. This is called dependency injection.
The below code sample depicts this.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import pytest
class MyObject:
def __init__(self, value):
self.value = value
def my_object():
return MyObject(“Hello, World!”)
def test_my_object(my_object):
assert my_object.value == “Hello, World!”
def my_dependent_object(my_object):
return MyObject(my_object.value + “ Again!”)
def test_my_dependent_object(my_dependent_object):
assert my_dependent_object.value == “Hello, World! Again!”
Here you can see the my_dependent_object
makes use of the my_object
fixture.
Unless necessary, I would avoid using dependent fixtures as it adds complexity and layering fixtures one over the other makes it hard to refactor in the future.
Auto Using Fixtures
If you’re looking for a simple trick to avoid defining the fixture in each test, you can use the autouse=True
flag as an argument in the fixture definition.
With autouse=True
, this fixture function will be automatically applied to all test functions without the need to explicitly pass it as an argument in each test function.
If you only want to use the fixture for certain test functions, you can specify the test function name(s) as a parameter to the pytest.fixture
decorator instead of using autouse=True
.
Fixture Scopes
Fixture scopes define the lifetime and visibility of fixtures.
The scope of a fixture determines how many times it will be called and how long it will live during the test session.
Available fixture scopes in Pytest:
function
: The fixture is created for each test function that uses it and is destroyed at the end of the test function. This is the default scope for fixtures.class
: The fixture is created once per test class that uses it and is destroyed at the end of the test class.module
: The fixture is created once per module that uses it and is destroyed at the end of the test session.session
: The fixture is created once per test session and is destroyed at the end of the test session.
To specify the scope of a fixture, you can pass the scope
parameter to the @pytest.fixture
decorator.
Choosing the right fixture scope depends on the purpose and usage of the fixture.
If a fixture is expensive to create, such as a database connection, you may want to use a higher scope to reuse the connection across multiple tests.
On the other hand, if a fixture is lightweight and specific to a single test, you can use the default "function"
scope.
Yield vs Return in Fixtures
You can use both yield
and return
statements to provide the fixture’s value to the test function, but they have different behaviours and implications.
When you use yield
in a fixture function, the set-up code is executed before the first yield
, and the tear-down code is executed after the last yield
.
Example of yield
:1
2
3
4
5
6
7import pytest
def my_fixture():
# set-up code
yield "fixture value"
# tear-down code
When you use return
in a fixture function, the set-up code is executed before the return statement, and the tear-down code is executed immediately after the return statement.
Example of return
:1
2
3
4
5
6
7
8import pytest
def my_fixture():
# set-up code
fixture_value = "fixture value"
# tear-down code
return fixture_value
In general, yield
is often used when you need to set up and tear down some resources for each test function, while return
is used when you only need to provide a simple value to the test function.
Where Should You Use Fixtures
Generally, a good use case for fixtures are
- Clients — Database clients, AWS or other cloud clients, API clients which require setup/teardown
- Test Data — Test data in JSON or another format can be easily imported and shared across tests
- Functions — Some commonly used functions can be used as fixtures
Conclusion
In this article, you learnt about the benefits of Pytest fixtures and how they make writing and maintaining tests easier.
You also learnt the basics of Pytest fixtures and how to define them.
You built a basic Calculator App powered by a Flask API and defined fixtures using conftest.py
and within the tests.
Lastly, you learnt about auto-use, scopes and how to parameterise your fixtures, which are all very powerful features of Pytest.
Fixtures can be used to set up database connections, load test data, initialize complex objects, or perform any other set-up or tear-down operations that are required for testing.
By using fixtures, you can write clean, modular, and maintainable test code that is easy to read and understand.
With a little practice and experimentation, you can leverage Pytest fixtures to make your testing process faster, more efficient, and more effective.
If you have ideas for improvement or like for me to cover anything specific, please send me a message via Twitter, GitHub or Email.
Till the next time… Cheers!