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?

Link To GitHub Repo

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

pytest-fixtures-repo

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) using

1
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
42
class 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
52
from flask import Flask, jsonify, request   
from calculator.core import Calculator

# Create the Flask application
app = Flask(__name__)

# Create the routes
@app.route(‘/’)
def index():
return ‘Index Page’

# Add a route for the add function
@app.route(‘/api/add/’, methods=[‘POST’])
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
@app.route(‘/api/subtract/’, methods=[‘POST’])
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
@app.route(‘/api/multiply/’, methods=[‘POST’])
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
@app.route(‘/api/divide/’, methods=[‘POST’])
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

  1. test_calculator_class.py — Tests the Calculator Class
  2. test_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
6
import pytest  
from calculator.core import Calculator

@pytest.fixture
def calculator():
return Calculator(2, 3)

Basic Calculator Testing with Predefined Fixture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def test_add(calculator):  
assert calculator.add() == 5


def test_subtract(calculator):
assert calculator.subtract() == -1


def test_multiply(calculator):
assert calculator.multiply() == 6


def test_divide(calculator):
assert calculator.divide() == 0.6666666666666666


def test_divide_by_zero(calculator):
calculator.b = 0
with pytest.raises(ZeroDivisionError):
calculator.divide()

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
43
import pytest  
from app.app import app


@pytest.fixture
def client():
with app.test_client() as client:
yield client


@pytest.fixture
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

  1. The Flask Client
  2. 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
32
import pytest  
from calculator.core import Calculator
from app.app import app


@pytest.fixture(scope="module")
def calculator():
return Calculator(2, 3)


@pytest.fixture
def custom_calculator(scope="module"):
def _calculator(a, b):
return Calculator(a, b)

return _calculator


@pytest.fixture(scope="module")
def client():
with app.test_client() as client:
yield client


@pytest.fixture(scope="module")
def json_headers():
return {"Content-Type": "application/json"}


@pytest.fixture(scope="module")
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
@pytest.fixture  
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
19
import pytest   

class MyObject:
def __init__(self, value):
self.value = value

@pytest.fixture
def my_object():
return MyObject(“Hello, World!”)

def test_my_object(my_object):
assert my_object.value == “Hello, World!”

@pytest.fixture
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:

  1. 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.
  2. class: The fixture is created once per test class that uses it and is destroyed at the end of the test class.
  3. module: The fixture is created once per module that uses it and is destroyed at the end of the test session.
  4. 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
7
import pytest  

@pytest.fixture
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
8
import pytest  

@pytest.fixture
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!

Additional Reading

About fixtures - pytest documentation

https://www.testim.io/blog/using-pytest-fixtures/