DEV Community

Cover image for 3 Ways to Unit Test REST APIs in Python
Miguel Brito
Miguel Brito

Posted on • Edited on • Originally published at miguendes.me

3 Ways to Unit Test REST APIs in Python

In this tutorial, we’ll learn how to test REST API calls in Python.

In another words, we'll see the best ways to unit test code that performs HTTP requests by using mocks, design patterns, and the VCR library.

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, 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?

That’s exactly what you'll learn in this post! I’ll not only show you how to do it but also demonstrate the pros and cons of each approach.

📃 Table of Contents

  1. Requirements
  2. 🌦️ Demo App Using a Weather REST API
  3. 🧪 Testing the API Calls Using Mocks
  4. 🧪 Testing the API Using an Adapter
  5. 🧪 Testing the API Using VCR.py
  6. 😎 Conclusion

✅ Requirements

  • Python 3.8
  • pytest-mock
  • requests
  • flask
  • responses
  • VCR.py

🌦️ Demo App Using a Weather REST API

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.

HTML page displaying the data returned after a GET request to the weather API
To get the information about the weather, we must find it somewhere. Fortunately, OpenWeatherMap provides everything we need through its REST API service.

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.

Retrieving the Data

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()
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

When we call resp.json(), it returns the data as a Python dictionary. In order to improve this data manipulation, 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"]),
        )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Good, we have the basic building blocks for our app. Before moving forward, let's unit test those functions.

🧪 1. Testing the API Using Mocks

According to wikipedia, a mock object is an object that simulates the behavior of a real object by mimicking it.

In Python, we can mock any object using the unittest.mock lib that is part of the standard library. To test the retrieve_weather function, we can then mock requests.get and return a static data.

pytest-mock

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.

To accomplish our mocking goals, we'll 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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Pros

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.

Cons

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
...
Enter fullscreen mode Exit fullscreen mode

Can we do better?

Yes, please, follow along. Let's see now how to improve it a bit.

responses

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)
Enter fullscreen mode Exit fullscreen mode

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 ===============================
Enter fullscreen mode Exit fullscreen mode

Great! This test pass too. But... It's still not that great...

Pros

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.

Cons

Again, the problem is, much like unittest.mock, our test is coupled to the implementation. If we replace requests, our test break.

🧪 2. Testing the API Using an Adapter

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And the adapter becomes this:

def requests_adapter(url: str) -> dict:
    resp = requests.get(url)
    return resp.json()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 ===============================
Enter fullscreen mode Exit fullscreen mode

Pros

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.

Cons

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()
Enter fullscreen mode Exit fullscreen mode

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.

🧪 3. Testing the API Using VCR.py

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Pros

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.

Cons

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.

😎 Conclusion

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

Top comments (1)

Collapse
 
codewander profile image
Kanishka • Edited

This is a great article.

I have been thinking about similar concerns within elixir. I am slowly moving from preferring vcr (http recording) towards preferring an adapter approach.

I am personally not worried about having my tests tied to a specific http library implementation, but I think mocking is overkill since I usually only need one static stub implementation rather than the fully dynamic behavior of a mock. Also, I like how using an adapter approach let's you express the stub in terms of higher level data than json and http parameters and body, leading to more concise stubs. When I implement an adapter, I perform format inside of the adapter, so the url is an implementation detail of the adapter.

There is also an option of temporarily running a fake server for each test, but that feels too heavyweight.

I see a slight disadvantage with the adapter style introducing an extra layer purely for testing, but it's worth it to me.

With the adapter approach, I would also write some tests for the format function as well as the function which translates the response.

When using the VCR approach, if you record against a live sandbox and you create data during your recordings, then you have to adjust the tests to create new isolated data only when you are recording new cassettes. This can lead to some manual changes every time you have to record again. There is also some extra overhead to add the sandbox credentials to the test configuration which the other patterns don't have to deal with.