3 Ways to Unit Test REST APIs in Python
Tags: python, programming, testing, tutorial
Published: 2021-07-22 08:08:25

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](https://enterprisecraftsmanship.com/posts/domain-model-isolation/). 

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](#requirements)
2. 🌦️ [Demo App Using a Weather REST API](#demo-app-using-a-weather-rest-api)
    - [Retrieving the Data](#retrieving-the-data)
3. 🧪 [Testing the API Calls Using Mocks](#1-testing-the-api-calls-using-mocks)
4. 🧪 [Testing the API Using an Adapter](#2-testing-the-api-using-an-adapter)
5. 🧪 [Testing the API Using `VCR.py`](#3-testing-the-api-using-vcrpy)
6. 😎 [Conclusion](#conclusion)

## ✅ Requirements 

- [Python 3.8](https://miguendes.me/how-i-set-up-my-python-workspace)
- `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](https://cdn.hashnode.com/res/hashnode/image/upload/v1600427026729/SbZKkiywH.png)
To get the information about the weather, we must find it somewhere. Fortunately,  [OpenWeatherMap](https://openweathermap.org/) 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`](https://github.com/psf/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.

```python
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.
```python
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:

```json
{
  "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
}
```
When we call `resp.json()`, it returns the data as a [Python dictionary](#https://miguendes.me/the-best-way-to-compare-two-dictionaries-in-python). In order to improve this data manipulation, we can represent them as a `dataclass`. This class has a [factory method](#https://miguendes.me/design-patterns-that-make-sense-in-python-simple-factory) 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!

```python
@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.

```python
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.

## 🧪 1. Testing the API Using Mocks

[According to wikipedia](https://en.wikipedia.org/wiki/Mock_object), 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](https://miguendes.me/7-pytest-plugins-you-must-definitely-use).

> 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.

```python
@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)
```

```python
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:
```console
============================= 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.

#### 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.

```python
...
    # 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.

### `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`.

```python
@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.

```console
============================= 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...

### 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...
```python
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:
```python
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:
```python
def requests_adapter(url: str) -> dict:
    resp = requests.get(url)
    return resp.json()
```

Now it's time to refactor our `retrieve_weather` function:

```python
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.

```python
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)
```

```python
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.

```python
@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:
```console
============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED    [100%]
============================== 1 passed in 0.22s ===============================
```

### Pros

The pro for this approach is that we successfully decoupled our test from the implementation. We use [dependency injection](https://stackoverflow.com/questions/130794/what-is-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:
```python
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.

## 🧪 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`](https://github.com/kevin1024/vcrpy) 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`.

```python
@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.

```yaml
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.

### 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](https://github.com/miguendes/tutorials/tree/master/testing_http).

Other posts you may like:

- [7 pytest Features and Plugins That Will Save You Tons of Time](https://miguendes.me/7-pytest-features-and-plugins-that-will-save-you-tons-of-time)

- [How to Use Fixtures as Arguments in pytest.mark.parametrize](https://miguendes.me/how-to-use-fixtures-as-arguments-in-pytestmarkparametrize)

- [How to Check if an Exception Is Raised (or Not) With pytest](https://miguendes.me/how-to-check-if-an-exception-is-raised-or-not-with-pytest)

- [7 pytest Plugins You Must Definitely Use](https://miguendes.me/7-pytest-plugins-you-must-definitely-use)

- [How to Disable Autouse Fixtures in pytest](https://miguendes.me/pytest-disable-autouse)

- [How to Unit Test Complex Data Like Numpy Arrays in Python](https://miguendes.me/how-to-test-complex-data-in-python)

See you next time!

This post was originally published at [https://miguendes.me](https://miguendes.me/3-ways-to-test-api-client-applications-in-python)