Imitating Objects Using Mocks

Learn how to use Python’s mocking techniques for effective unit testing.

Isolated problems are easier to diagnose and solve. Figuring out why a gasoline car won’t start can be tricky because there are so many interrelated parts. If a test fails, uncovering all the interrelationships makes diagnosis of the problem difficult. We often want to isolate items by providing simplified imitations. It turns out there are two reasons to replace perfectly good code with imitation (or mock) objects:

  • The most common case is to isolate a unit under test. We want to create collaborating classes and functions so we can test one unknown component in an environment of known, trusted test fixtures.
  • Sometimes, we want to test code that requires an object that is either expensive or risky to use. Things like shared databases, filesystems, and cloud infrastructures can be very expensive to set up and tear down for testing.

In some cases, this may lead to designing an API to have a testable interface. Designing for testability often means designing a more usable interface, too. In particular, we have to expose assumptions about collaborating classes so we can inject a mock object instead of an instance of an actual application class.

For example, imagine we have some code that keeps track of flight statuses in an external key-value store (such as redis or memcache), such that we can store the timestamp and the most recent status. The implementation will require the redis client; it’s not needed to write unit tests.

Example

Here’s some code that saves status in a redis cache server:

Press + to interact
from __future__ import annotations
import datetime
from enum import Enum
import redis
class Status(str, Enum):
CANCELLED = "CANCELLED"
DELAYED = "DELAYED"
ON_TIME = "ON TIME"
class FlightStatusTracker:
def __init__(self) -> None:
self.redis = redis.Redis(host = "127.0.0.1", port = 6379, db = 0)
def change_status(self, flight: str, status: Status) -> None:
if not isinstance(status, Status):
raise ValueError(f"{status!r} is not a valid Status")
key = f"flightno:{flight}"
now = datetime.datetime.now(tz=datetime.timezone.utc)
value = f"{now.isoformat()}|{status.value}"
self.redis.set(key, value)
def get_status(self, flight: str) -> tuple[datetime.datetime, Status]: key = f"flightno:{flight}"
value = self.redis.get(key).decode("utf-8")
text_timestamp, text_status = value.split("|")
timestamp = datetime.datetime.fromisoformat(text_timestamp)
status = Status(text_status)
return timestamp, status

The Status class defines an enumeration of four string values. We’ve provided symbolic names like Status.CANCELLED so that we can have a finite, bounded domain of valid status values. The actual values stored in the database will be strings like "CANCELLED" that – for now – happen to match the symbols we’ll be using in the application. In the future, the domain of values may expand or change, but we’d like to keep our application’s symbolic names separate from the strings that appear in the database. It’s common to use numeric codes with Enum, but they can be difficult to remember.

There are a lot of things we ought to test for in the change_status() method. We check to be sure the status argument value really is a valid instance of the Status enumeration, but we could do more. We should check that it raises the appropriate error if the flight argument value isn’t sensible. More importantly, we need a test to prove that the key and value have the correct formatting when the set() method is called on the redis object.

One thing we don’t have to check in our unit tests, however, is that the redis object is storing the data properly. This is something that absolutely should be tested in integration or application testing, but at the unit test level, we can assume that the py-redis developers have tested their code and that this method does what we want it to. As a rule, unit tests should be self-contained; the unit under test should be isolated from outside resources, such as a running Redis instance.

Instead of integrating with a Redis server, we only need to test that the set() method was called the appropriate number of times and with the appropriate arguments. We can use Mock() objects in our tests to replace the troublesome method with an object we can introspect. The following example illustrates the use of Mock:

Press + to interact
import datetime
import flight_status_redis
from unittest.mock import Mock, patch, call
import pytest
@pytest.fixture
def mock_redis() -> Mock:
mock_redis_instance = Mock(set = Mock(return_value = True))
return mock_redis_instance
@pytest.fixture
def tracker(
monkeypatch: pytest.MonkeyPatch, mock_redis: Mock
) -> flight_status_redis.FlightStatusTracker:
fst = flight_status_redis.FlightStatusTracker()
monkeypatch.setattr(fst, "redis", mock_redis)
return fst
def test_monkeypatch_class(tracker: flight_status_redis.FlightStatusTracker, mock_redis: Mock) -> None:
with pytest.raises(ValueError) as ex:
tracker.change_status("AC101", "lost")
assert ex.value.args[0] == "'lost' is not a valid Status"
assert mock_redis.set.call_count == 0

This test uses the raises() context manager to make sure the correct exception is raised when an inappropriate argument is passed in. In addition, it creates a Mock object for the redis instance that the FlightStatusTracker will use.

The mock object contains an attribute, set, which is a mock method that will always return True. The test, however, makes sure the redis.set() method is never called. If it is, it means there is a bug in our exception handling code.

Note the navigation into the mock object. We use mock_redis.set to examine the mocked set() method of a ...