19
3 Ways to Unit Test REST APIs in Python
In this tutorial, we’ll learn how to unit test code that performs HTTP requests. In another words, we'll see the art of API unit testing in Python.
Unit tests are meant to test a single unit of behavior. In testing, a well-known rule of thumb is to isolate code that reaches external dependencies.
For instance, when testing a code that performs HTTP requests, we it's recommended to replace the real call by a fake call during test time. This way we can to unit test it without performing a real HTTP request every time we run the test.
The question is, how can we isolate the code?
Hopefully, that’s what I’m going to answer in this post! I’ll not only show you how to do it but also weigh the pros and cons of each approach.
- Python 3.8
pytest-mock
requests
flask
responses
VCR.py
To put this problem in context, let's imagine that we're building a weather app. This app uses a third-party weather REST API to retrieve weather information for a particular city. One of the requirements is to generate a simple HTML page, like the image below.
Ok, that's cool, but how can we use it?
We can get everything we need by sending a GET request to: 'https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric'
. For this tutorial, we’ll parametrize the city name and settle with the metric unit.
To retrieve the weather data, we'll use requests
. We can create a function that receives a city name as parameter and returns a json. The json will contain the temperature, weather description, sunset, sunrise time and so on.
The example below illustrates such function.
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
resp = requests.get(url)
return resp.json()
The URL is made up from two global variables.
BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
API = BASE_URL + "?q={city_name}&appid={api_key}&units=metric"
The API returns a json in this format:
{
"coord": {
"lon": -0.13,
"lat": 51.51
},
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"base": "stations",
"main": {
"temp": 16.53,
"feels_like": 15.52,
"temp_min": 15,
"temp_max": 17.78,
"pressure": 1023,
"humidity": 72
},
"visibility": 10000,
"wind": {
"speed": 2.1,
"deg": 40
},
"clouds": {
"all": 0
},
"dt": 1600420164,
"sys": {
"type": 1,
"id": 1414,
"country": "GB",
"sunrise": 1600407646,
"sunset": 1600452509
},
"timezone": 3600,
"id": 2643743,
"name": "London",
"cod": 200
}
The data is returned as a python dictionary when we call resp.json()
. In order to encapsulate all the details, we can represent them as a dataclass
. This class has a factory method that gets the dictionary and returns a WeatherInfo
instance.
This is good because we keep the representation stable. For example, if the API changes the way it structures the json, we can change the logic in just one place, in the from_dict
method. So, other parts of the code won’t be affected. We can even get information from different sources and combining them in the from_dict
method!
@dataclass
class WeatherInfo:
temp: float
sunset: str
sunrise: str
temp_min: float
temp_max: float
desc: str
@classmethod
def from_dict(cls, data: dict) -> "WeatherInfo":
return cls(
temp=data["main"]["temp"],
temp_min=data["main"]["temp_min"],
temp_max=data["main"]["temp_max"],
desc=data["weather"][0]["main"],
sunset=format_date(data["sys"]["sunset"]),
sunrise=format_date(data["sys"]["sunrise"]),
)
Now, let's create a function called retrieve_weather
. We'll use this function to call the API and return a WeatherInfo
so we can build our HTML page.
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city)
return WeatherInfo.from_dict(data)
Good, we have the basic building blocks for our app. Before moving forward, let's unit test those functions.
For this tutorial, we’ll use pytest
as our testing framework of choice. pytest
is a very extensible library that allows the extension through plugins. And to accomplish our mocking goals, let’s use pytest-mock
. This plugin abstracts a bunch of setups from unittest.mock
and makes our testing code very concise. If you are curious, I discuss more about it in another blog post.
Ok, enough talking, show me the code.
Here's a complete test case for the retrieve_weather
function. This test uses two fixtures, one is the mocker
fixture provided by the pytest-mock
plugin. The other one is ours. It's just the static data we saved from a previous request.
@pytest.fixture()
def fake_weather_info():
"""Fixture that returns a static weather data."""
with open("tests/resources/weather.json") as f:
return json.load(f)
def test_retrieve_weather_using_mocks(mocker, fake_weather_info):
"""Given a city name, test that a HTML report about the weather is generated
correctly."""
# Creates a fake requests response object
fake_resp = mocker.Mock()
# Mock the json method to return the static weather data
fake_resp.json = mocker.Mock(return_value=fake_weather_info)
# Mock the status code
fake_resp.status_code = HTTPStatus.OK
mocker.patch("weather_app.requests.get", return_value=fake_resp)
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
If we run the test, we get the following output:
============================= test session starts ==============================
...[omitted]...
tests/test_weather_app.py::test_retrieve_weather_using_mocks PASSED [100%]
============================== 1 passed in 0.20s ===============================
Process finished with exit code 0
Great, our tests pass! But... Life is not a bed of roses. This test has pros and cons. Let's take a look at them.
Well, one pro we already discussed is that by mocking the return of the API we make our tests easier. We isolate the communication with the API and make the test predictable. It will always return what we want.
As cons, the problem is, what if we don’t want to use requests
anymore and decide to go with the standard’s lib urllib
. Every time we change the implementation of find_weather_for
we will have to adapt the test. A good test is a test that doesn’t change when our implementation change. So, by mocking, we end up coupling our test with the implementation.
Also, another downside is the amount of setup we have to do before calling the function. At lest, 3 lines of code.
...
# Creates a fake requests response object
fake_resp = mocker.Mock()
# Mock the json method to return the static weather data
fake_resp.json = mocker.Mock(return_value=fake_weather_info)
# Mock the status code
fake_resp.status_code = HTTPStatus.OK
...
Can we do better?
Yes, please, follow along. Let's see now how to improve it a bit.
Mocking requests
using the mocker
feature has the downside of having a long setup. A good way to avoid that is to use a library that intercepts requests
calls and patch it. There are more than one lib for that, but simplest to me is responses
. Let’s see how can we use it to replace mock
.
@responses.activate
def test_retrieve_weather_using_responses(fake_weather_info):
"""Given a city name, test that a HTML report about the weather is generated
correctly."""
api_uri = API.format(city_name="London", api_key=API_KEY)
responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPStatus.OK)
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Again, this function makes use of our fake_weather_info
fixture.
Good... let's run the test.
============================= test session starts ==============================
...
tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED [100%]
============================== 1 passed in 0.19s ===============================
Great! This test pass too. But... It's still not that great...
The good thing about using libraries like responses
is that we don't need to patch requests
ourselves. We save some setup by delegating the abstraction to the library. However, in case you haven't noticed, we have problems.
Again, the problem is, much like unittest.mock
, our test is coupled to the implementation. If we replace requests
, our test break.
If by using mocks we couple our tests, what can we do?
Let’s image the following scenario: Say that we can no longer use requests
and we’ll have to replace it by urllib
, since it comes with Python. Not only that, we learned the lessons of not coupling test code with implementation and we want to avoid that in the future. We want to replace urllib
and not have to rewrite the tests.
It turns out we can abstract away the code that performs the GET request.
Really? How?
We can abstract it by using an adapter. The adapter is a design pattern that is used to encapsulate, or wrap, the interface of other class and expose it as a new interface. This way we can change the adapters without changing our code. For example, we can encapsulate the details about requests
in our find_weather_for
and expose it via a function that takes only the URL.
So, this...
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
resp = requests.get(url)
return resp.json()
Becomes this:
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
return adapter(url)
And the adapter becomes this:
def requests_adapter(url: str) -> dict:
resp = requests.get(url)
return resp.json()
Now it's time to refactor our retrieve_weather
function:
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city, adapter=requests_adapter)
return WeatherInfo.from_dict(data)
So, if we decide to change this implementation by an one that uses urllib
, we can just swap the adapters.
def urllib_adapter(url: str) -> dict:
"""An adapter that encapsulates urllib.urlopen"""
with urllib.request.urlopen(url) as response:
resp = response.read()
return json.loads(resp)
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city, adapter=urllib_adapter)
return WeatherInfo.from_dict(data)
Ok, how about the tests?
To test retrieve_weather
we can just create a fake adapter that is used during test time.
@responses.activate
def test_retrieve_weather_using_adapter(
fake_weather_info,
):
def fake_adapter(url: str):
return fake_weather_info
weather_info = retrieve_weather(city="London", adapter=fake_adapter)
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
If we run the test we get:
============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED [100%]
============================== 1 passed in 0.22s ===============================
The pro for this approach is that we successfully decoupled our test from the implementation. We use dependency injection to inject a fake adapter during test time. Also, we can swap adapter at any time, including during runtime. We did all of this without changing the behavior.
The cons are that, since we’re using a fake adapter for tests, if we introduce a bug in the adapter we use in the implementation, our test won’t catch it. For example, let’s say that we pass a faulty parameter to requests
, like this:
def requests_adapter(url: str) -> dict:
resp = requests.get(url, headers=<some broken headers>)
return resp.json()
This adapter will fail in production, and our unit tests won’t catch it. But truth to be told, we also have the same problem with the previous approach. That’s why we always need to go beyond unit tests and also have integration tests. But I’ll discuss them in another post. That being said, let’s consider another option.
Now it’s finally the time to discuss our last option. I have only found about it quite recently, frankly. I’ve been using mocks for a long time and always had some problem with them. VCR.py
is a library that simplifies a lot the tests that make HTTP requests.
It works by recording the HTTP interaction the first you run the test as a flat yaml
file called cassette. Both the request and the response are serialized. When we run the test for the second time, VCR.py
will intercept the call and return response for the request made.
Let's now see how to test retrieve_weather
using VCR.py
.
@vcr.use_cassette()
def test_retrieve_weather_using_vcr(fake_weather_info):
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Wow, is that it? No setup? What is that
@vcr.use_cassette()
?
Yes, that’s it! We have no setup, just a pytest
annotation to tell VCR to intercept the call and save the cassette file.
How does the cassette file look like?
Good question, there's a bunch of things in it. This is because VCR saves every detail of the interaction.
interactions:
- request:
body: null
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-requests/2.24.0
method: GET
uri: https://api.openweathermap.org/data/2.5/weather?q=London&appid=<YOUR API KEY HERE>&units=metric
response:
body:
string: '{"coord":{"lon":-0.13,"lat":51.51},"weather":[{"id":800,"main":"Clear","description":"clearsky","icon":"01d"}],"base":"stations","main":{"temp":16.53,"feels_like":15.52,"temp_min":15,"temp_max":17.78,"pressure":1023,"humidity":72},"visibility":10000,"wind":{"speed":2.1,"deg":40},"clouds":{"all":0},"dt":1600420164,"sys":{"type":1,"id":1414,"country":"GB","sunrise":1600407646,"sunset":1600452509},"timezone":3600,"id":2643743,"name":"London","cod":200}'
headers:
Access-Control-Allow-Credentials:
- 'true'
Access-Control-Allow-Methods:
- GET, POST
Access-Control-Allow-Origin:
- '*'
Connection:
- keep-alive
Content-Length:
- '454'
Content-Type:
- application/json; charset=utf-8
Date:
- Fri, 18 Sep 2020 10:53:25 GMT
Server:
- openresty
X-Cache-Key:
- /data/2.5/weather?q=london&units=metric
status:
code: 200
message: OK
version: 1
That's a lot!
Indeed! The good thing is that we don’t need to care much about it. VCR.py
takes care of that for us.
Now, for the pros, I can list at least five things:
- No setup code
- Tests remain isolated, so it’s fast
- Tests are deterministic
- If we change the request, like by using faulty headers, the test will fail.
- Not coupled to the implementation, we can swap the adapters and the test will pass. The only thing that matters is that our request is the same.
Again, despite the enormous benefits compared to mocking, we still have problems.
If, for some reason, the API provider changes the format of the data, our test will still pass. Fortunately, this is not very frequent and API providers usually version their APIs before introducing such breaking changes. Also, unit tests are not meant to access the external API, so there isn’t much we can do here.
Another thing to consider is having end-to-end tests in place. These tests will call the server every time it runs. As the name says, it’s a more broad test and slow. They are meant to cover a lot more ground than unit tests. In fact, not every project will need to have them. So, in my view, VCR.py
is more than enough for our needs.
This is it, I hope you’ve learned something useful today. Testing API client applications can be a bit daunting. Yet, when armed with the right tools and knowledge, we can tame the beast.
You can find the full app on my github.
Other posts you may like:
See you next time!
This post was originally published at https://miguendes.me/
See you next time!
19