In this post I will highlight the most important features of unit testing with Pytest, Python's most popular testing framework!
What is Unit Testing and Why does it Matter?
Unit testing is a software development process in which the smallest units of code are tested for proper operation. The smallest units of code in Python are typically functions. Ensuring the proper operation of functions will decrease the number of bugs throughout a codebase. Unit testing also forces you to write testable code and when code is testable it is generally cleaner. Functions are more likely to adhere to the principle of doing one thing & one thing only and the codebase will be more readable. Unit testing is a process that every development team should practice!
What is Pytest?
Pytest is Python's most popular testing framework. Before you can start writing unit tests with Pytest, you must download and install Pytest as a package in your project. Pytest can be installed from PyPI by entering the following command into your terminal
pip install pytest.
Create a Unit Test:
Once installed you can start writing unit tests! Below is an example of a Python Projects directory structure:
geometry.py there is the following method
is_triangle. This method determines if 3 side lengths can form a triangle:
class Geometry: def is_triangle(side_1: int, side_2: int, side_3: int) -> bool: """ Given 3 side lengths, determine if the sides can create a triangle. :param side_1: A side of a potential triangle :param side_2: A side of a potential triangle :param side_3: A side of a potential triangle """ is_triangle = False if (side_1 + side_2 > side_3) and (side_1 + side_3 > side_2) and (side_2 + side_3 > side_1): is_triangle = True return is_triangle
We now want to test that this method behaves as expected. We will want to ensure that this method returns True or False for valid or invalid side lengths. Since we are testing for both valid and invalid triangles, we will make 2 separate unit tests inside
from src.geometry import Geometry def test_is_a_triangle(): """Ensure that given a valid set of triangle sides the is_triangle method will return True.""" assert Geometry.is_triangle(6,6,6) def test_is_not_a_triangle(): """Ensure that given an invalid set of triangle sides the is_triangle method will return False.""" assert not Geometry.is_triangle(0, 1, 2)
Within these tests you will notice the
assert keyword. In order for a test to pass, the
assert keyword must be followed by the boolean value
True. In the first test, we expect
Geometry.is_triangle(6,6,6) to return
True and thus we write
assert Geometry.is_triangle(6,6,6). In the second test, we expect
Geometry.is_triangle(0, 1, 2) to return
False and thus we write
assert not Geometry.is_triangle(0, 1, 2). (Note - Unit Tests can contain multiple assert statements).
In addition to how these tests are written, notice how the test module is prefixed with
test_geometry.py) and the test functions are prefixed with
test_is_a triangle). This is not personal preference, Pytest requires this naming convention in order to recognize which tests to collect and run. When the
pytest command is entered into the terminal, Pytest will search the current directory for modules prefixed with
test_ and functions, within those modules, prefixed with
test_. Once Pytest has identified and collected all these tests, it will run them! Below is an image of me running the unit tests:
In the image above, the 2 green dots represent the 2 tests that were found in the
test_geometry.py module. They are colored green which represents that they have passed. If a test fails, the test will be marked with a red F instead of a green dot. Helpful debug information will also be displayed in the terminal to show you where and why the test failed.
Create a Fixture:
As a test suit grows, you may find yourself duplicating data throughout tests. One way to combat duplication is through the use of Pytest Fixtures.
Fixtures are functions that provide data to setup your tests. They are decorated with
@pytest.fixture and they return a value. Once a fixture has been made, it can be used as an argument in unit test functions. Unit tests, that have a fixture as an argument, will have access to the return value of the fixture. Below is an example of 3 new functions that calculate volumes of 3D shapes; below that is a fixture and the corresponding unit tests:
def volume_rectangular_prism(length: float, width: float, height: float) -> float: return length * width * height def volume_rhombic_prism(long_diagonal: float, short_diagonal, length: float) -> float: return long_diagonal * short_diagonal * length * 0.5 def volume_rectangular_pyramid(length: float, width: float, height: float) -> float: return length * width * height / 3
Fixture and Unit Tests:
import pytest from src.geometry import Geometry def dimension() -> list[float]: return [1.0, 2.0, 3.0] def test_volume_rectangular_prism(dimension): """Ensure that the rectangular_prism function calculates the volume correctly.""" assert Geometry.volume_rectangular_prism(dimension, dimension, dimension) == 6.0 def test_volume_rhombic_prism(dimension): """Ensure that the rhombic_prism function calculates the volume correctly.""" assert Geometry.volume_rhombic_prism(dimension, dimension, dimension) == 3.0 def test_volume_rectangular_pyramid(dimension): """Ensure that the rectangular_pyramid function calculates the volume correctly.""" assert Geometry.volume_rectangular_pyramid(dimension, dimension, dimension) == 2.0
The fixtures name is
dimension and it is used as an argument in all 3 test functions. The dimension values are then accessed through list indices. While this isn't the most practical use case of a fixture (we could've just written the numbers into each test), hopefully you can see how duplication across a test suit can be minimized when fixtures are used.
Parameterize a Unit Test:
The last concept that is frequently used in unit testing is parameterization. Parameterization allows you to write 1 unit test that can be run multiple times with different parameters. For example, when we tested the
is_triangle method we only tested the side lengths (6, 6, 6), an equilateral triangle. But we may want to test other combinations of numbers for the other types of triangles (isosceles and scalene). Without parameterization we'd have to write 3 separate unit tests:
def test_is_an_equilateral_triangle(): """Ensure that given a valid set of equilateral triangle sides, the is_triangle method will return True.""" assert Geometry.is_triangle(6,6,6) def test_is_an_isosceles_triangle(): """Ensure that given a valid set of isosceles triangle sides, the is_triangle method will return True.""" assert Geometry.is_triangle(4,4,6) def test_is_an_scalene_triangle(): """Ensure that given a valid set of scalene triangle sides, the is_triangle method will return True.""" assert Geometry.is_triangle(4,5,6)
With Pytest Parameterization we can condense these 3 unit tests into 1. A parameterized unit test will be decorated with
@pytest.mark.parametrize(). This decorator will accept a string argument, which will be the variables name, followed by an argument that is a list of values that the variable will take on for each iteration of the unit test. In the code below, the variable that will be parameterized is named
side and this variable will take on the values [6, 6, 6], [4, 4, 6] & [4, 5, 6] as the unit test is iterated over 3 times:
def test_is_a_triangle(side): """Ensure that given a valid set of equilateral/isosceles/scalene triangle sides, is_triangle will return True.""" assert Geometry.is_triangle(side, side, side)
When the test is run, you can see that 3 tests were run (represented by the 3 green dots) even though only 1 test was written!
Unit testing is a critical part of software development. In Python, Pytest is the best unit testing framework. Pytest is equipped with all the tools you need for any unit testing concept you'd like to exercise [fixtures, parameterization, mocking, etc.]. If you wish to learn more about Pytest and its capabilities, below are some links to other great resources!
Great Article That Goes More In-Depth - Real Python Pytest
Official Documentation - Pytest Official Documentation