Pytest Conftest With Best Practices And Real Examples

Are you a python backend developer looking to improve your TDD (Test Driven Development) skills?

Or perhaps you heard or seen a colleague usepytest conftest in a PR and don’t understand why or how they used it?

Well, you’ve come to the right place.

While there’s a lot of documentation on how to use conftest , the best practices using real repeatable examples are often lacking.

In this article, we’ll cover some basics about

  • What are Pytest Fixturesand how to use them to improve Unit Testing.
  • How are Pytest Fixtures defined traditionally?
  • Why defining Fixtures in each Unit Test is a Bad Idea?
  • New ways to define and share Fixtures across tests using pytest conftest
  • How to leverage external Pytest plugins with conftest.py

So let’s begin.

Link To GitHub Repo

What Are Pytest Fixtures?

As we’ll cover this in detail in another article, I’ll keep this brief and explain only the key points you need, to understand how to use conftest.py.

To explain Pytest Fixtures let’s start with a Real Example.

The above repo contains code that calculates the area of 4 different planar shapes — Triangle , Rectangle , Square and Circle .

The class is initialised with 4 optional variables — radius , side , base and height .

Some of these could be the same but for this example let’s assume they are different.

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
class AreaPlaneShapes:
def __init__(self, radius=None, base: int | float = None,
height: int | float = None, side: int | float = None) \
-> None:
"""
Function to initialise the Area of Plane Shapes Class
https://www.mathsisfun.com/area.html
Parameters:
radius - Radius of the circle
base - Base of the triangle
height - Height of the triangle or rectangle
side - Side of the Square and Rectangle
Return:
None
"""
# Validate `radius` of type int | float
if isinstance(radius, int | float | None):
self.radius = radius
else:
raise ValueError("Input `radius` should be of "
"Type - Int or Float")

# Validate `base` and `height` of type int | float
if isinstance(base, int | float | None) and \
isinstance(height, int | float | None):
self.base = base
self.height = height
else:
raise ValueError("Inputs `base` and `height` "
"should be of Type - Int or Float")

# Validate `side` of type int | float
if isinstance(side, int | float | None):
self.side = side
else:
raise ValueError("Input `side` should be "
"of Type - Int or Float")

def area_of_triangle(self) -> str:
"""
Function to calculate Area of Triangle
Required Parameters - Base and Height,
Type - int | float
:return: Area, Type - str
"""
try:
if all(item is not None for item in [self.base, self.height]):
area = round(0.5 * self.base * self.height, 2)
return f"The Area of the Triangle is `{area}` units"
else:
return "Area Unavailable. " \
"Please initialise Class with `Base` and `Height`"
except Exception as err:
logging.error(err)

def area_of_circle(self) -> str:
"""
Function to calculate Area of Circle
Required Parameters - Radius, Type - int | float
:return: Area, Type - str
"""
try:
if self.radius is not None:
area = round(math.pi * pow(self.radius, 2), 2)
return f"The Area of the Circle is `{area}` units"
else:
return "Area Unavailable. " \
"Please initialise Class with `Radius`"
except Exception as err:
logging.error(err)

def area_of_square(self) -> str:
"""
Function to calculate Area of Square
Required Parameters - Side, Type - int | float
:return: Area, Type - str
"""
try:
if self.side is not None:
area = round(pow(self.side, 2), 2)
return f"The Area of the Square is `{area}` units"
else:
return "Area Unavailable. " \
"Please initialise Class with `Side`"
except Exception as err:
logging.error(err)

def area_of_rectangle(self) -> str:
"""
Function to calculate Area of Rectangle
Required Parameters - Side and Height, Type - int | float
:return: Area, Type - str
"""
try:
if all(item is not None for item in [self.side, self.height]):
area = round(self.side * self.height, 2)
return f"The Area of the Rectangle is `{area}` units"
else:
return "Area Unavailable. " \
"Please initialise Class with `Side` and `Height`"
except Exception as err:
logging.error(err)

As you can see the above class contains 4 methods to calculate Area, along with an __init__ method.

Now let’s jump to the Unit Tests.

In the file test_core_fixtures.py we test the functional implementation of the various methods.

We need to initialise the class AreaPlaneShapes()somewhere.

A fixture is used to initialise test functions that can be repeatedly used across several test functions (my unofficial definition).

This documentation from PyTest covers Fixtures in detail.

Defining Pytest Fixtures - Old Way?

Fixtures are traditionally defined using the @pytest.fixture decorator.

Out test_core_fixtures.py unit test file has 3 fixtures.

  1. area_fixture - Initialise the class with standard variables
  2. area_fixture_side_none - Initialise the class with Side=None
  3. area_fixture_height_none - Initialise the class with Height=None

test_core_fixtures.py (Example Of Fixture)

1
2
3
4
5
6
@pytest.fixture(scope="class")
def area_fixture():
return AreaPlaneShapes(radius=2,
base=2,
height=4,
side=3)

test_core_fixtures.py (Example Of Unit Test Using Fixture)

1
2
3
4
5
6
7
8
def test_area_of_triangle(area_fixture) -> None:
"""
Unit Test for Area of Triangle
:return: None
"""
expected_response = "The Area of the Triangle is `4.0` units"
actual_response = area_fixture.area_of_triangle()
assert actual_response == expected_response

These are 3 independent Class initialisations that can be used across tests.

Note we’ve tested various functionality and error handling in the test file using the fixtures.

Without this, we’d have to initialise the AreaPlaneShapes() class for each and every unit test.

So fixtures help us save time and write less code.

Defining Pytest Fixtures using conftest.py?

While this is great, do you see any problems with it?

Well, this is a very simple example.

Let’s say, instead of just 1 core.py file, we have many nested classes, spread across several Python files.

This means in TDD terms, we’d have to write lots of Unit Tests.

For simplicity, we would split these into several test_*.py files.

In our example, I’ve split the Unit Tests into 2 files — test_core_functionality.py and test_core_error_handling.py .

Each of these files test a different part of the source code.

So if we had 10 test files, we’d have to define Fixtures in each of them. There would be a lot of repetition.

There’s a better way, and that’s probably why you’re here.

The conftest.py file will help us solve this. Luckily, pytest and other testing frameworks are intelligent to automatically pick up fixtures from conftest.py .

In our example, you can see how we’ve defined 4 fixtures in conftest.py and used them extensively across all test files.

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
@pytest.fixture(scope="class")
def area_fixture():
return AreaPlaneShapes(radius=5,
base=3,
height=4,
side=7)


@pytest.fixture(scope="class")
def area_fixture_invalid_height():
return AreaPlaneShapes(radius=5,
base=3,
height="INVALID",
side=7)


@pytest.fixture(scope="class")
def area_fixture_none_radius():
return AreaPlaneShapes(
base=3,
height=4,
side=7)


@pytest.fixture(scope="class")
def area_fixture_none_side_height():
return AreaPlaneShapes(radius=5,
base=3
)

Unit Tests can now use the fixtures directly from conftest.py. All we have to do it pass the fixture as an argument to the Unit Test.

test_core_error_handling.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def test_area_circle_none_radius(area_fixture_none_radius) -> None:
"""
Unit Test for Area of Circle with None Radius
:return: None
"""
expected_response = "Area Unavailable. " \
"Please initialise Class with `Radius`"
actual_response = area_fixture_none_radius.area_of_circle()
assert actual_response == expected_response


def test_area_rectangle_none_side_height(area_fixture_none_side_height) -> None:
"""
Unit Test for Area of Rectangle with None Side and Height
:return: None
"""
expected_response = "Area Unavailable. " \
"Please initialise Class with `Side` and `Height`"
actual_response = area_fixture_none_side_height.area_of_rectangle()
assert actual_response == expected_response

This provides convenience and predictability, all while writing less code.

Other Uses of Conftest.py

Maybe you’re wondering… OK great, improved way of writing sharable fixtures. But what else can I do with conftest.py

Well, here are a few uses, succinctly specified in this Stack Overflow Post.

Load External PyTest Plugins

While most external plugins can be simply installed via pip and automatically picked up by Pytest, you may want to include some custom packages.

In that case, including them in conftest.py allows you to share them across your entire test suite.

For e.g.

pytest_plugins = ("myapp.testsupport.myplugin")

You can find an entire list of pytest plugins here.

An example of using PyTest Plugins pytest-pikachu and pytest-progress .
pytest-conftest

While there are other benefits of conftest for example - use in Hook Functions, it’s by far most used for Fixtures and Parameters. 

We’ll cover more use cases of conftest in another article or a subsequent update.

Conclusion

This was a fairly short article but covers most of what you need to know about using conftest.py to write more readable and efficient unit tests.

We covered an introduction on Pytest Fixtures including a real example of how they’re applied within the individual test file and also shared using conftest.py .

I hope you appreciate the efficiency that conftest provides in allowing you to share fixtures and parameters across your unit tests.

Lastly, we briefly looked at how to leverage Pytest External Plugins.

Please let me know if you’d like to learn something more specific about this topic or another and I’ll do my best to include them.

Till the next time… Cheers!