Lock Your Mock
Posted by Aly Sivji in Quick Hits
Python 3.7 was released a few weeks back. Lots of new features in this minor release: data classes, new breakpoint keyword, dicts preserving insertion order, amongst others.
One feature that hasn't got as much press, or really any press, is an update to the unittest.mock
module in the Standard Library. Starting in Python 3.7, instances of Mock
and MagicMock
can be sealed, preventing the creation of attribute mocks.
In this quick hit, we will explore mock.seal
.
Test Doubles¶
Test doubles refer to objects which are used to replace production objects for testing purposes.
When our program is required to reach outside of our function, class, or module to interact with the outside world, we have to account for this external dependency. This adds an additional layer of complexity to our testing process.
We can create test doubles with predefined behaviors and replace them with production objects to make our tests deterministic.
In the following example, we have a function that hits a REST API endpoint to get historical stock market data. Next, we iterate thru the data to find the day with the largest intraday spread (intraday_spread = daily_high - daily_low
) for a given ticker.
# stock_spread.py
from collections import defaultdict
from decimal import Decimal
import json
import requests
def calculate_max_spread(stock):
"""Find day with max spread (daily hi - daily lo) for a stock ticker"""
url = "https://www.alphavantage.co/query"
params = {
"function": "TIME_SERIES_DAILY",
"symbol": "MSFT", # demo key works for MSFT only
"apikey": "demo"
}
r = requests.get(url, params=params)
if r.status_code != 200:
raise Exception
daily_stock_prices = r.json()["Time Series (Daily)"]
daily_spreads = []
spread_by_date = defaultdict(list)
for date, stock_data in daily_stock_prices.items():
hi = Decimal(stock_data["2. high"])
lo = Decimal(stock_data["3. low"])
spread = float(hi - lo)
daily_spreads.append(spread)
spread_by_date[spread] = date
max_spread = max(daily_spreads)
max_spread
return (spread_by_date[max_spread], max_spread)
This code has a dependency on an external data source. If the 3rd party website goes down, our function will raise an Exception
. This will result in test failures where errors could be caused either by our code or the status of a third party website. We can't be sure unless we dig into the failure.
To test this code, we can replace the external APIs with mock objects we create. Specifically, we need to patch requests.get
to return deterministic test data. I explored this pattern in a previous blog post on pytest fixtures.
unittest.mock
¶
In Python, the distinction between test doubles is blurred as the unittest.mock
framework provides a robust implementation of test doubles we can use for different types of test verification.
From the Standard Library Documentation:
MagicMock objects create all attributes and methods as you access them and store details of how they have been used. You can configure them, to specify return values or limit what attributes are available, and then make assertions about how they have been used
Let's explore this in more detail.
from unittest.mock import MagicMock
my_mock = MagicMock(name="my_mock")
my_mock
We can add attributes and functions to our my_mock
object; this will return a new MagicMock
.
my_mock.new_attribute
my_mock.new_function.return_value = 2
my_mock.new_function
my_mock.new_function()
Notice the id
of the above objects are different. This means they different objects.
Mocks are used for behavior verficiation testing, i.e. we can check that our mock was used how we think it was used. Let's confirm our new_function
mock was called:
my_mock.new_function.assert_called_once()
# Call new_function once more
my_mock.new_function()
# Should raise `AssertionError`
my_mock.new_function.assert_called_once()
As we can create attributes on our mocks on demand, there is a possibility that our tests can pass with typos in our assert
method.
# notice the spelling error: asert vs assert
my_mock.new_function.asert_called_once()
Because of the flexibility of MagicMock, i.e. it returns a truthy value instead of raising an AssertionError
.
This can cause tests to pass when they should fail.
mock.seal
¶
In Python 3.7, we can strictly define mock attributes:
The new seal() function allows sealing Mock instances, which will disallow further creation of attribute mocks. The seal is applied recursively to all attributes that are themselves mocks. (Contributed by Mario Corchero in bpo-30541.)
import sys
from unittest import mock
print(sys.version)
sealed_mock = MagicMock(name="sealed_mock")
sealed_mock
sealed_mock.only_function.return_value = 2
sealed_mock.only_function
Now let's seal the mock:
mock.seal(sealed_mock)
# add new attribute results in error
sealed_mock.new_function
Sealing our mock prevents the creation of unwanted attributes.
We could also autospec mocks to create objects that have the same attributes as the production objects we are replacing. See: mock.create_autospec
, mock.patch
, mock.patch.object
.
mock.seal
offers additional flexibility to spec out minimal interfaces for testing.
Patching External API with Mock¶
Going back to our stock example from above, we can test our module by monkeypatching the requests library with a mock. This mock returns known data which we can use to write deterministic tests.
I am currently working on a series of testing blog posts that will go into this in more detail. For now, I will leave y'all with a snippet that shows how I would go about testing.
def json_loader(filename):
"""Loads data from JSON file"""
with open(filename, 'r') as f:
data = json.load(f)
return data
def test_calculate_max_spread():
# Arrange
get_mock = mock.MagicMock()
get_mock.status_code = 200
get_mock.json.return_value = json_loader("stock_data.json")
mock.seal(get_mock)
# Act
with mock.patch.object(requests, 'get', return_value=get_mock):
response = calculate_max_spread("MSFT")
# Assert
assert response == ('2018-03-27', 6.629)
Comments