Link Search Menu Expand Document

Testing with pytest

The pytest package allows you to write and run tests for your code easily. It is a de facto industry standard. If you ever thought you did not have time to write tests, pytest removes that excuse. Write tests. Always. It is very easy.

First, we need something to test. Everything starts with code that has requirements and expected logic. Once we have it, tests ensure these requirements are always met.

For our example, we will write code to detect if the International Space Station (ISS) is flying overhead. Then, we will write tests to ensure it works correctly.

Our project requirements are:

  • πŸ›°οΈ The ISS position is obtained from the API http://api.open-notify.org/iss-now.json.
  • πŸ“ Our position on Earth, with longitude and latitude, is known and passed in the constructor.
  • πŸ“ Knowing our coordinates and those of the ISS, we determine if it is passing overhead.

The API returns the following JSON, including the ISS position, time, and request success status.

{
    "message": "success",
    "iss_position": {
        "longitude": "31.9896",
        "latitude": "-26.6497"
    },
    "timestamp": 1732180867
}

Using this API requires Internet access. We will later see how to make tests independent of external services.

The ISS orbits at a low altitude, about 418 km, circling the Earth every 90 minutes.

With this information, we are ready to write our code. We define a class DistanceISS.

# iss.py
from geopy.distance import geodesic
from math import sqrt
import requests

class DistanceISS:
    URL = "http://api.open-notify.org/iss-now.json"
    T = 0.01

    def __init__(self, lat, lon):
        self.lat = lat
        self.lon = lon
    
    def coordinates_iss(self):
        try:
            response = requests.get(self.URL)
            response.raise_for_status()
            iss_data = response.json()
            iss_lat = float(iss_data['iss_position']['latitude'])
            iss_lon = float(iss_data['iss_position']['longitude'])
            return iss_lat, iss_lon
        except (requests.RequestException, ValueError, KeyError) as e:
            raise ValueError(f"Error getting ISS position: {str(e)}")
        
    def above(self):
        iss_lat, iss_lon = self.coordinates_iss()
        lat_diff = abs(iss_lat - self.lat)
        lon_diff = abs(iss_lon - self.lon)
        return lat_diff <= self.T and lon_diff <= self.T

The methods are as follows:

  • __init__: Constructor that takes our geographical coordinates of latitude and longitude.
  • coordinates_iss: Uses the API to return the current position of the ISS in latitude and longitude.
  • above: Returns True if the ISS is above us, with a tolerance of T.

We can use it by indicating our coordinates, corresponding to Buenos Aires. If it returns True, the ISS is passing over Buenos Aires.

iss = DistanceISS(-34.6037, -58.3816)
print(iss.above())
# False

Now that we have the code, let us write tests to ensure it works correctly and protect it from future modifications. We start with a simple test for __init__, verifying that the latitude lat and longitude lon are stored correctly.

# iss_test.py
import pytest
from iss import DistanceISS

def test_init():
    distance_iss = DistanceISS(10, 20)
    assert distance_iss.lat == 10
    assert distance_iss.lon == 20

To run the test, use the following command in the terminal.

pytest -v

You will get a report indicating that the test has passed, meaning all asserts meet the expected condition.

iss_test.py::test_init PASSED [100%]

The above command automatically searches for all tests in the folder. To run tests in a specific file, use:

pytest -v iss_test.py

To run a specific test:

pytest -v iss_test.py::test_init

Congratulations, you have your first test and can run it. Now, every time you make a change, you can run it to ensure nothing is broken. But there is more to test.

Next, we write a test for coordinates_iss. We want to verify that we correctly extract the latitude and longitude from the API response. However, there is an external dependency: the API requires Internet access.

A good practice is to use a mock to simulate the API, allowing the test to run without Internet access.

It is important to define the boundaries of what we want to test. In unit tests, we focus on our code. If the API stops working, our test should still work, as the code is fine.

We can mock the API as follows, instructing Python to return a specific value when requests.get is called in the iss module. We indicate None in raise_for_status to signify no issues.

# iss_test.py
import pytest
from unittest.mock import patch
from iss import DistanceISS

def test_coordinates_iss():
    with patch('iss.requests.get') as mock_get:
        mock_get.return_value.json.return_value = {
            'iss_position': {
                'latitude': '-50.0',
                'longitude': '-30.1',
            },
            'message': 'success',
            'timestamp': 1596563200
        }
        mock_get.return_value.raise_for_status = lambda: None
        
        distance_iss = DistanceISS(0, 0)
        assert distance_iss.coordinates_iss() == (-50.0, -30.1)

The test verifies that for the mocked response, the longitude and latitude are correctly extracted. But we cannot trust anyone; we must see what happens when the result is unexpected.

Imagine the API is modified and mistakenly returns incorrect longitude and latitude parameters, such as text instead of numbers. A test should ensure an exception is raised. It may seem obvious, but imagine that the code processes text and returns a valid coordinate pair. This would be dangerous.

def test_coordinates_iss_non_numerics():
    with patch('iss.requests.get') as mock_get:
        mock_get.return_value.json.return_value = {
            'iss_position': {
                'latitude': 'text',
                'longitude': 'text',
            },
            'message': 'success',
            'timestamp': 1596563200
        }
        mock_get.return_value.raise_for_status = lambda: None
        
        distance_iss = DistanceISS(0, 0)
        with pytest.raises(Exception, match="Error"):
            distance_iss.coordinates_iss()

Now imagine the API is offline. We can simulate this with our mock by using raise_for_status with an exception, verifying that coordinates_iss propagates the exception and does not return coordinates.

def test_coordinates_iss_error():
    with patch('iss.requests.get') as mock_get:
        mock_get.return_value.json.return_value = {}
        mock_get.return_value.raise_for_status.side_effect = Exception("Error")
        
        distance_iss = DistanceISS(0, 0)
        with pytest.raises(Exception, match="Error"):
            distance_iss.coordinates_iss()

Let us continue writing tests for above. This function calls coordinates_iss, which uses the API requiring Internet, so we will use a mock.

With this mock, we simulate coordinates_iss returning the coordinates of Madrid.

def test_above():
    with patch.object(DistanceISS, 'coordinates_iss', return_value=(40.4167, -3.7033)):
        iss = DistanceISS(40.4167, -3.7033)
        assert iss.above() == True

At this point, we have several tests to verify our code works correctly. We have covered expected paths and potential issues, ensuring behavior is as desired in all cases.