Python Libraries

Pytest

Pytest is a leading testing library in the python ecosystem. With its easy syntax, rich featureset, and flexibility pytest stands as a fantastic tool for testing your applications. In this tutorial, we will first be covering the difference between unit testing and integration testing. After getting a general background behind the purpose of both types of tests, we will be covering all of Pytest’s essential features including writing basic tests, creating fixtures, marking tests, parameterizing tests and mocking dependencies. Combining both testing theory with hands-on Pytest experience enables you to build more reliable software and become a better python developer.

Automated Testing Theory: Unit Testing VS Integration Testing

Before we dive into Pytest, lets understand the basics behind the different types of automated tests and where Pytest fits into the automated testing ecosystem. In general, testing software is often summarized by the following pyramid:

[IMAGE]

Let’s start at the foundation of the pyramid, unit testing, and work our way up. Unit testing comprises of isolating one function in your application and verifies if that function works as expected. These tests typically run in isolation and don’t have any other dependencies such as a database or network capabilities. Because of this quality, unit tests can be executed very quickly. On the downside, it delivers lower confidence than other tests because it either focuses on a single isolated function without dependencies or relies on mocking to mimic the behavior of dependencies such as an external API call or database setup.

Integration tests — the next level in our testing pyramid — considers the dependencies of our application from the beginning and test how multiple isolated parts of our application work together as a whole. In an integration test, we won’t be mocking away dependencies for any of the code we are testing. With all dependencies taken into account, integration tests give us that next level of confidence for testing our code base.

Project Setup

For this tutorial, we will be testing a simple locator app. The app simply takes a start location and a list of destinations and returns a sorted list of the closest destination given your start location and destinations. In this tutorial, we will be focused on testing and not the application itself. Below I have provided the file structure, requirements, and source code for the application we will be testing.

""" requirements.txt """

pytest==8.2.2
geopy==2.4.1
""" src/class_locator """

from src.functions_locator import GEO
from geopy import distance


class Locator():


    def __init__(self, start, end, destinations):
        self.start: str = start
        self.end: str = end
        self.destinations: list[str] = destinations


    def get_location(self, location):
        """ returns coordinates tuple (latitude, longitude) """
        location = GEO.geocode(location)
        return (location.latitude, location.longitude)



    def get_distance(self, start: str, end: str):
        """ 
        takes two (lat, long) coordinates and returns the distance between the two locations in miles (mi).
        """

        loc_1 = self.get_location(start)
        loc_2 = self.get_location(end)

        return distance.distance(loc_1, loc_2).miles
    
    
    def get_closest_destination(self, start: str, destinations: list[str]):
        """ 
        takes a start location and a list of possible destinations and returns a sorted list
        """
        res = []
        for destination in destinations:
            res.append({
                "start": start,
                "end": destination,
                "distance": self.get_distance(start, destination)
            })
        
        return sorted(res, key=lambda x: x["distance"])
""" src/functions_locator.py """

from geopy.geocoders import Nominatim
from geopy import distance
from geopy.extra.rate_limiter import RateLimiter
import math


GEO = Nominatim(user_agent="pytest_app")


def convert_mi_km(miles: float):
    return miles * 1.609


def get_location(location: str):
    """ returns coordinates tuple (latitude, longitude) """
    geocode = RateLimiter(GEO.geocode, max_retries=3, min_delay_seconds=1)
    location = geocode(location)
    return (location.latitude, location.longitude)


def get_distance(start: str, end: str):
    """ 
    takes two (lat, long) coordinates and returns the distance between the two locations in miles (mi).
    """

    loc_1 = get_location(start)
    loc_2 = get_location(end)

    return distance.distance(loc_1, loc_2).miles


def get_closest_destinations(start: str, destinations: list[str]):
    """ 
    takes a start location and a list of possible destinations and returns a sorted list
    """
    res = []
    for destination in destinations:
        res.append({
            "start": start,
            "end": destination,
            "distance": get_distance(start, destination)
        })
        # print(res)
    
    return sorted(res, key=lambda x: x["distance"])

Notice that src/functions_locator and src/class_locator accomplish the same exact thing except one uses object oriented programming and the other functional in order to best demonstrate different features with pytest.

Writing Our First Test

Pytest will run all files of the form test_*.py or *_test.py in the current directory and its subdirectories. More generally, it follows standard test discovery rules. Let’s create a file called test_locator.py in our tests directory and paste the following code:

""" tests/test_locator.py """
from unittest import mock
import time

import pytest

from main import START, DESTINATIONS
from src.class_locator import Locator
from src.functions_locator import get_distance, convert_mi_km, get_closest_destinations, get_location
from .effects import get_location_side_effect

def test_convert_mi_km():
    res = convert_mi_km(100)
    assert res == 160.9
The code above tests our convert_mi_km function and ensures that if we pass 100 miles into our conversion function we will indeed get the correct kilometer conversion.

To invoke the tests we simply need to run pytest tests in the terminal.

It is also common that we ensure that the test will raise an error if the inputs to our function are incorrect. Consider the following test:

""" test_locator.py """

def test_convert_mi_km_error():
    with pytest.raises(TypeError):
        res = convert_mi_km("error string")
This test will actually pass because the pytest.raises function allows us to ensure that the function indeed raises an error when you pass in invalid input.

Fixtures

Pytest fixtures are a way to use repeated functionality across multiple tests. Let’s say we have a class, and we would like to test all of the functionality within that class. Instead of re-instantiating the class multiple times for every test, we can create a fixture that we can pass into each of our tests that will provide an instance of that class for use in our tests. I created a Locator class, so we could demonstrate this functionality:

""" test_locator.py """
...

@pytest.fixture
def return_locator():
    return Locator(start="1345 Piedmont Ave NE, Atlanta, GA 30309", 
                   end="225 Baker St NW, Atlanta, GA 30313",
                   destinations=[
                        "225 Baker St NW, Atlanta, GA 30313",
                        "1280 Peachtree Rd NE, Atlanta, GA 30309",
                        "130 W Paces Ferry Rd NW, Atlanta, GA 30305",
                    ])


def test_get_location(return_locator: Locator):
    loc = get_location(return_locator.start)
    loc = (round(loc[0], 1), round(loc[1], 1))
    assert loc == (33.8, -84.4)

Marking Tests With Attributes

In Pytest, we can also give our tests attributes. For example, if we would like to skip a certain test, we can mark the function with a “skip” attribute. Additionally, if we know a test will fail we can mark it with an attribute that will let pytest know that our tests will fail. The following tests accomplish both of these things:

""" test_locator.py """

@pytest.mark.skip(reason='This is a duplicate test')
def test_get_start_location():
    res = get_location(START)
    res = (round(res[0], 1), round(res[1], 1))
    assert res == (33.8, -84.4)

@pytest.mark.xfail
def test_string_conversion():
    res = convert_mi_km("NAN")
    assert res == 160.9

Pytest also gives you the option to specify your own custom test by modifying the pytest.ini file in the root directory for your project. One common use case for this would be to mark tests that are expected to be slow. For example:

""" pytest.ini """

[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    serial

Now Pytest will register tests marked with slow.

""" test_locator.py """
...
@pytest.mark.slow 
def test_slow_test():
    time.sleep(5)
    assert True

@pytest.mark.slow
def test_get_closest_destinations():
    expected_res = [
        {
            'start': '1345 Piedmont Ave NE, Atlanta, GA 30309', 
            'end': '1280 Peachtree Rd NE, Atlanta, GA 30309', 
            'distance': 1.3833221260978183
        }, 
        {
            'start': '1345 Piedmont Ave NE, Atlanta, GA 30309', 
            'end': '225 Baker St NW, Atlanta, GA 30313', 
            'distance': 2.1896362466079027}, 
        {
            'start': '1345 Piedmont Ave NE, Atlanta, GA 30309', 
            'end': '130 W Paces Ferry Rd NW, Atlanta, GA 30305', 
            'distance': 3.6011566093031533
        }
    ]
    res = get_closest_destinations(START, DESTINATIONS)
    
    assert res == expected_res

To invoke all tests not marked with slow we can run: pytest tests -m 'not slow' Conversely, we can run all of our slow tests with:

pytest tests -m 'slow'

We can use the -m flag to run only specific mark types or exclude certain mark types from a given test run.

Parameterizing Tests

What if we would like to test edge cases. Maybe we need to test several cases — base cases, edge cases and everything in between. Pytest gives us the parametrize function to add as many custom cases as we would like causing pytest to run that test function multiple times, one for each case. Let’s take our convert_mi_km function and run it multiple times with a different number of miles each time.

""" test_locator.py """
...
@pytest.mark.parametrize("miles,expected", [
    (10, 16.09),
    (100, 160.9),
    (1000, 1609)
])
def test_multiple_convert_mi_km(miles, expected):
    res = convert_mi_km(miles)
    assert res == expected

Now we can test our code more rigorously by leveraging the parameterization of our tests.

Mocking

Remember, when unit testing we want to mock away our dependencies and isolate the functionality of one specific piece of code. In our example, we want to test the get_distance function, but this function depends on leveraging geopy’s geocoder, which is a resource that depends on network access. To mock away this dependency we can leverage the mock module from the unittest Python library.

""" test_locator.py """
...

@mock.patch("src.functions_locator.get_location")
def test_get_distance(mock_get_location):
    """ test get_distance """

    mock_response = mock.MagicMock(side_effect=get_location_side_effect)
    mock_get_location = mock_response

    res = get_distance("timbuck too", "jungle")
    print(res)
    assert type(res) == float

Let’s break this down. The @mock.patch function takes in a path to the function that you would like to mock. Then, we can use that

Class Based Tests

The last feature I wanted to display is that you are not limited to functional tests. You can group tests into classes and pytest will still be able to discover your tests. In class based tests, we have access to a setup_method and a teardown_method that allows us to run a function before all of our tests to “prepare” for testing. In the following example, we leverage the setup method to create a Locator class before all of our tests run.

""" locator_test.py """

from src.class_locator import Locator

class TestLocator:
    
    def setup_method(self, method):
        print(f"SETTING Up Locator method: {method}")
        self.locator = Locator(start="1345 Piedmont Ave NE, Atlanta, GA 30309", 
                               end="225 Baker St NW, Atlanta, GA 30313",
                               destinations=[
                                   "225 Baker St NW, Atlanta, GA 30313",
                                   "1280 Peachtree Rd NE, Atlanta, GA 30309",
                                   "555 Fake Address NE, Atlanta, GA 55555",
                                   "130 W Paces Ferry Rd NW, Atlanta, GA 30305",
                               ])

    def test_get_distance(self):
        dist = self.locator.get_distance(self.locator.start, self.locator.end)
        assert type(dist) == float # TODO: replace with actual distance

    def teardown_method(self, method):
        # TODO show example with cleaning up temporary files
        print(f"tearing down Locator method: {method}")

Conclusion

You now understand the theory behind unit testing and integration testing. Understanding the testing ecosystem and the purpose for all of the testing mechanisms is paramount for developing robust software. Complementing your theoretical knowledge, you are equipped with the tools Pytest provides to write your own unit and integration tests. Additionally, you can write fixtures, mark your tests, mock dependencies, and write class based tests. These skills will go a long way when it comes to making your code more reliable.

Are you a backend engineer, data scientist or machine learning engineer? If so, checkout some of my lists where I have full courses and informative tutorials teaching machine learning and backend engineering.

Let’s Connect LinkedIn Twitter

Previous
Exponential Distribution