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:
from __future__ import annotationsimport datetimefrom enum import Enumimport redisclass 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
:
import datetimeimport flight_status_redisfrom unittest.mock import Mock, patch, callimport pytest@pytest.fixturedef mock_redis() -> Mock:mock_redis_instance = Mock(set = Mock(return_value = True))return mock_redis_instance@pytest.fixturedef tracker(monkeypatch: pytest.MonkeyPatch, mock_redis: Mock) -> flight_status_redis.FlightStatusTracker:fst = flight_status_redis.FlightStatusTracker()monkeypatch.setattr(fst, "redis", mock_redis)return fstdef 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 ...