Python: Unit Testing with Pytest

Python: Unit Testing with Pytest

Most important Pytest features!

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: image.png Inside 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 test_geometry.py:

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_ (test_geometry.py) and the test functions are prefixed with test_ (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:

image.png

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:

Functions:

    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

@pytest.fixture
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[0], dimension[1], dimension[2]) == 6.0

def test_volume_rhombic_prism(dimension):
    """Ensure that the rhombic_prism function calculates the volume correctly."""
    assert Geometry.volume_rhombic_prism(dimension[0], dimension[1], dimension[2]) == 3.0

def test_volume_rectangular_pyramid(dimension):
    """Ensure that the rectangular_pyramid function calculates the volume correctly."""
    assert Geometry.volume_rectangular_pyramid(dimension[0], dimension[1], dimension[2]) == 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:

@pytest.mark.parametrize('side', [[6, 6, 6], [4, 4, 6], [4, 5, 6]])
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[0], side[1], side[2])

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! image.png

Conclusion:

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!

Additional Resources:

Great Article That Goes More In-Depth - Real Python Pytest

Official Documentation - Pytest Official Documentation