There are some things that need to be mocked in unit tests.
For example: API clients for other backend services - we don't want to run an instance of the other service just for our unit tests. The other service will have its own tests and we only want to test that our code confiorms to the API contract of the other service. Similarly for 3rd party services - we don't want our unit tests to connect out over the internet to talk to the 3rd party service (even if they offer a 'sandbox' test environment) for the same reasons as above, and because this is a recipe for flaky tests.
(There is certainly a role for integration tests which do make live calls to other services, but the bulk of tests won't be this kind and need mocking).
Python has the excellent mock library to help with this.
However, say you have six API clients for backend services which are used extensively in many places in code for your mobile app backend. You're going to end up with a big 'stack' of patch decorators on many tests, e.g.:
@mock.patch('services.users.client.get_user', return_value=MockUser(id=1))
@mock.patch('services.products.client.get_product', return_value=MockProduct(id=1))
@mock.patch('services.paypal.client.make_payment', return_value=PaypalResult('success'))
def test_some_web_view(self, *mocks)
Say you have thousands of unit tests, these decorators need applying to many of them. Every time you write a new test you'll need to remember to patch things.
Enter automock
.
Basically we want some functions to be 'mocked by default' when running tests.
But we also need to be able to easily replace the default mocks in some cases,
where the test needs a specific return value. automock
makes this easy-ish.
pip install automock
The key idea is that we define a 'mock factory' for each function we want to be
automocked. When called without arguments the factory should return a suitable
'default' mock that will allow most tests to pass. The default mock factory is
just MagicMock
from the mock
library.
Registering a function to be mocked is simple:
import automock
automock.register('services.users.client.get_user')
By default this provides a MagicMock
and is equivalent to decorating all
your test cases with:
@mock.patch('services.users.client.get_user')
(for more scenarios see Customising mock factories below)
For this to work you just need to do two things.
You need to ensure that the modules containing
automock.register
calls get imported before the tests run. To achieve this we have anAUTOMOCK_REGISTRATION_IMPORTS
config setting. This should contain string paths to modules containing registration calls, e.g.:AUTOMOCK_REGISTRATION_IMPORTS = ( 'services.users.test_mocks', 'services.products.test_mocks', 'services.paypal.test_mocks', )
If you're running your tests under pytest then you don't need to do anything else - Automock registers a pytest plugin (named
automock
in pytest) that ensures your test cases all run patched.If you're running under another test-runner then your test cases need to inherit from one of our helper classes, e.g.:
from automock import AutomockTestCase, AutomockTestCaseMixin class TestWebViews(AutomockTestCase): ... class TestSpecialViews(AutomockTestCaseMixin, MyCustomTestCase): ...
This will ensure the mock patches get applied before the tests run, and stopped afterwards.
Alternatively you can start/stop patching manually:
from unittest import TestCase import automock class TestStuff(TestCase): # as a decorator @automock.activate() def test_stuff(self): # automocks active ... # as a context-manager def test_other_stuff(self): # automocks inactive ... with automock.activate(): # automocks active ... # automocks inactive
Settings are intended to be configured primarily via a python file, such
as your existing Django settings.py
. To bootstrap this, there are a couple
of env vars to control how config is loaded:
AUTOMOCK_APP_CONFIG
should be an import path to a python module, for example:AUTOMOCK_APP_CONFIG=django.conf.settings
AUTOMOCK_CONFIG_NAMESPACE
Sets the prefix used for loading further config values from env and config file. Defaults toAUTOMOCK
.
The following config keys are available (and are prefixed with
AUTOMOCK_
by default, see AUTOMOCK_CONFIG_NAMESPACE
above):
<namespace>_REGISTRATION_IMPORTS
list of import paths to modules containingautomock.register
calls
An important point to note about the path you mock:
This has the same caveats as when using mock.patch
directly. Namely that
you must patch the path where it is imported.
For example if you do:
# mypackage/mymodule.py
from services.product.client import get_product
When you patch it:
# won't work:
patch('services.product.client.get_product')
# works:
patch('mypackage.mymodule.get_product')
DON'T DO THIS (see this blog post for more details).
This import style will cause us problems if we want to mock-by-default all usages of a particular function, because we only register a single path to mock.
Instead you need to use one of the following import styles everywhere in your codebase that the function to mocked is used:
# mypackage/mymodule.py
# either
from services.product import client as product_client
product_client.get_product(*args)
# or
import services.product.client as product_client
product_client.get_product(*args)
This will ensure that we can:
automock.register('services.product.client.get_product')
and have that work reliably.
NOTE:
Always import automock
and use as automock.register
to ensure there is
only one registry active.
It's likely you need to do more than provide a bare MagicMock
. For example
we might want to customise the response based on some values from the request.
In mock.Mock
this is achieved via a 'side effect'. So we might want to
define our mock factory like this:
def batch_counters_mock(return_value=None, side_effect=None, *args, **kwargs):
if return_value is None and side_effect is None:
def side_effect(product_ids, *args, **kwargs):
return {str(p_id): 0 for p_id in product_ids}
return mock.MagicMock(return_value=return_value, side_effect=side_effect, *args, **kwargs)
automock.register('services.products.client.batch_counters', batch_counters_mock)
Note that we passed the custom mock factory as second argument to register
.
As an alternative we can use decorator syntax:
@automock.register('services.products.client.batch_counters')
def batch_counters_mock(return_value=None, side_effect=None, *args, **kwargs):
if return_value is None and side_effect is None:
def side_effect(product_ids, *args, **kwargs):
return {str(p_id): 0 for p_id in product_ids}
return mock.MagicMock(return_value=return_value, side_effect=side_effect, *args, **kwargs)
Now in our tests we can:
import services.products.client as products_client
def test_counters():
counters = products_client.batch_counters([1, 2])
# we got a default value for each of the ids we passed in:
assert counters == {'1': 0, '2': 0}
(This is a useless test of course, it's just to demonstrate the mocking)
Okay. What if we need a custom return value for a particular test?
Well, firstly the regular mock.patch
still works, you could apply that in
your test case.
Automock also provides a swap_mock
helper that allows us to take advantage
of our custom mock factory.
Let's say our factory looks like:
@automock.register('services.things.client.do_something')
def do_something_mock(success=True):
if success:
return mock.MagicMock(return_value='OK')
else:
return mock.MagicMock(side_effect=requests.HTTPError())
In our tests we can:
import pytest
import requests
from automock import swap_mock
import services.things.client as things_client
def test_success():
# default mock from factory gives success response
assert things_client.do_something() == 'OK'
@swap_mock('services.things.client.do_something', success=False)
def test_fail():
# swap mock applies a customised mock from our factory
with pytest.raises(requests.HTPPError):
things_client.do_something()
What happened here is that the *args, **kwargs
from our swap_mock
call
are passed through to the do_something_mock
to get a new mock which is
then applied in place of the default.
We can also use this as a context manager:
import pytest
import requests
from automock import swap_mock
import services.things.client as things_client
def test_do_something():
assert things_client.do_something() == 'OK'
with swap_mock('services.things.client.do_something', success=False):
with pytest.raises(requests.HTPPError):
things_client.do_something()
assert things_client.do_something() == 'OK'
It's common in tests to want to check if a mocked function was called, and
with correct arguments etc. If you use mock.patch
directly this is easy
because it returns the mock object to you.
Automock provides the get_mock
helper to achieve the same thing:
from automock import get_mock
import services.things.client as things_client
def test_success():
assert things_client.do_something() == 'OK'
mocked = get_mock('services.things.client.do_something')
assert mocked.called
Ok, so you've mocked your API clients or whatever. How do you test the mocked functions themselves if they're mocked out everywhere?
Firstly, you could just not inherit from AutomockTestCase
in those tests.
But maybe you have a bunch of other automocks you want to keep in place still.
Automock provides an unmock
helper:
import pytest
import responses
from automock import unmock
import services.things.client as things_client
@responses.activate
@unmock('services.things.client.do_something')
def test_do_something_not_found():
responses.add(responses.GET, 'https://thingservice.ourcompany.com/api/1/something',
json={'error': 'Not Found'}, status=404)
with pytest.raises(requests.HTPPError):
things_client.do_something()
(for functions which make HTTP calls we recommend the excellent responses library)
Here we have un-mocked our client method so that we can test that it correctly handles a 404 response from the remote service.
This project is tested against:
Python 2.7 | |
Python 3.6 | |
Python 3.7 | |
Python 3.8 |
The cli does not support 'workflows' at the moment so you have to run the two Python version jobs separately:
circleci build --job python-2.7
circleci build --job python-3.6
It's also possible to run the tests locally, allowing for debugging of errors that occur.
Now decide which Python version you want to test and create a virtualenv:
pyenv virtualenv 3.6.4 automock
pip install -r requirements-test.txt
Now we can run the tests:
make test