Testing External Resources With unittest.mock in Python

May 6th, 2019

tutorial

Python has an amazing community built around testing, with libraries like pytest or hypothesis complimenting the standard library. However, I have met a few Python programmers who've never used unittest.mock for their tests, even though it's built right into the language.

This serves as a quick introduction to using mocks for external resources aimed at beginners. In my examples, I will be using pytest style tests. Of course, mock also works with unittest style class based tests!

Photo by @elliotengelmann on Unsplash.

What's a Mock?

If you're not familiar, a mock serves as a stand-in for an external resource. Oftentimes, an external resource is a database, a REST API, or another service over the network.

Mocking external resources is beneficial to your tests for a few different reasons, including:

  • Mocks speed up your tests by removing expensive external resource calls.
  • You can more easily test edge cases. For example, a mock can raise a 500 Internal Error on command.
  • Using a mock isolates what you are testing. Without using a mock, you are both testing your code and the resource, and that's not a unit test.

So mocking sounds good, right? Right. Let's get started!

Using MagicMock

Looking at a simple example, lets see how we can test a class that interacts with redis-py. This class is a toy example, but it follows a common pattern for objects that use external resources: references or interfaces to the external object are placed into the instance.

class RedisCache:
    """Cache the view data in Redis."""

    def __init__(self, redis=Redis()):
        self.redis = redis

    def cache_view(self, path: str, rendered: str, ttl=30):
        """Cache a view at `path` on redis."""
        self.redis.set(path, rendered, ex=ttl)

With mocks, this pattern becomes trivially easy to test. We can just swap out the Redis() instance with our mock when we create our instance of RedisCache():

from unittest import mock


def test_redis_cache_stores_view():
    """Tests set() is called on the redis instance."""
    redis_mock = mock.MagicMock()
    redis_cache = RedisCache(redis_mock)

    redis_cache.cache_view("/", "<h1>Index</h1>")
    redis_mock.set.assert_called_once()

Wow, magical. 🌈✨

MagicMock is a subclass of Mock that's already setup to use. In most cases, MagicMock will work fine. Plus, it has helpful methods like .assert_called_once(), which we are using above.

It's important to note this test doesn't really test anything. In reality, you should be focusing on testing your business logic. Use mocks to remove large, expensive, or unrelated resources and focus on what matters.

Patching Resources

"This is all great, Maddie, but I have a bunch of functions that use requests. There's no way I'm adding keyword arguments to all of them just for tests."

That's what mock.patch is for. In some cases, you can't access the object being used (barring import shenanigans). Instead, mock.patch enables you to inject a mock object into whatever module or code you are testing.

Here is an example function that uses requests to grab an external resource:

import requests

def pull_url(url: str) -> str:
    """This is literally requests.get(url).text"""
    return requests.get(url).text

Now we can patch this in our test code. Here's an example:

# Make sure to replace requests in your file/module.

@mock.patch('mymodule.requests.get')
def test_get_url(requests_mock):
    mock_resp = mock.MagicMock()
    mock_resp.text = "<h1>Hello world</h1>"
    requests_mock.return_value = mock_resp

    assert get_url("some path") == "<h1>Hello world</h1>"

Patch can also be used as a context manager, so the above can be rewritten like this if you're not a fan of decorators:

def test_get_url(requests_mock):
    """Context syntax."""
    with mock.patch('mymodule.requests.get') as mock_resp:
        mock_resp = mock.MagicMock()
        mock_resp.text = "<h1>Hello world</h1>"
        requests_mock.return_value = mock_resp

        assert get_url("some path") == "<h1>Hello world</h1>"

For requests specifically, it can be annoying to mock many different responses that are all similar. If you repeat this pattern a lot in your code, it can be helpful to make a small constructor:

def resp_mock(resp: str, resp_type="TEXT"):
    """Construct your response.

    Probably want to change this depending on what responses you frequently need.

    Used like this:
        @mock.patch('mymodule.requests.get', return_value=resp_mock("<html>"))
    """
    if resp_type == "TEXT":
        mock_resp = mock.MagicMock()
        mock_resp.text = resp
        return mock_resp
    elif resp_type == "JSON":
        # You can also construct your json objects, etc.

However, you don't need to, especially if you only use requests in one or two places. I personally prefer to keep my tests mostly self-contained, to keep things easy to reason about.

Creating mock constructors is also a bit suspicious. If you need mocks that complicated, maybe you should reach for a conventional test double.

Simple is better than complex.

- Tim Peters, "The Zen of Python"

Testing Exceptions

Some exceptions, such as a resource timing out or an authentication error, are difficult to test without mocks. However, mock makes testing exceptions a breeze with side effects:

import pytest
from unittest import mock


def test_mocks_raise_exceptions():
    """Demonstrates how mocks can raise exceptions."""
    my_mock = mock.MagicMock()
    my_mock.side_effect = ValueError

    with pytest.raises(ValueError):
        my_mock()

Combined with the above techniques, it's simple to test uncommon exceptions with external resources. Coverage increases because your error handling code is tested, and you get peace of mind.

Conclusion and Further Reading

Mocking resources is often necessary to thoroughly unit test code. I hope this article either served as a introduction to mock to beginners, or a helpful review!

To learn more about mocking resources in Pythom, the documentation is always a great resource. Thanks for reading!