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
: ReturnsTrue
if the ISS is above us, with a tolerance ofT
.
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.