When writing tests, this regularly occurs: you are testing not only your own code but also the interaction with external resources, such as hardware, databases, web hosts, servers, and others. Some of these can be run safely, but certain tests are too slow, too dangerous, or even impossible to run. In those cases, mock objects are your friends; they can be used to fake anything, so you can be certain that your code still returns the expected results without having any variation from external factors.
The unittest.mock
library provides two base objects, Mock
and MagicMock
, to easily mock any external resources. The Mock
object is just a general generic mock object and MagicMock
is mostly the same, but it has all the magic methods such as __contains__
and __len__
defined. In addition to this, it can make your life even easier. This is because in addition to creating mock objects manually, it is possible to patch objects directly using the patch
decorator/context manager.
The following function uses random
to return True
or False
given governed by a certain probability distribution. Due to the random nature of a function like this, it is notoriously difficult to test, but not with unittest.mock
. With the use of unittest.mock,
it's easy to get repeatable results:
from unittest import mock import random def bernoulli(p): return random.random() > p @mock.patch('random.random') def test_bernoulli(mock_random): # Test for random value of 0.1 mock_random.return_value = 0.1 assert bernoulli(0.0) assert not bernoulli(0.1) assert mock_random.call_count == 2
Wonderful, isn't it? Without having to modify the original code, we can make sure that random.random
now returns 0.1
instead of some random number. For completeness, the version that uses a context manager is given here:
from unittest import mock import random def bernoulli(p): return random.random() > p def test_bernoulli(): with mock.patch('random.random') as mock_random: mock_random.return_value = 0.1 assert bernoulli(0.0) assert not bernoulli(0.1) assert mock_random.call_count == 2
The possibilities with mock objects are nearly endless. They vary from raising exceptions on access to faking entire APIs and returning different results on multiple calls. For example, let's fake deleting a file:
import os from unittest import mock def delete_file(filename): while os.path.exists(filename): os.unlink(filename) @mock.patch('os.path.exists', side_effect=(True, False, False)) @mock.patch('os.unlink') def test_delete_file(mock_exists, mock_unlink): # First try: delete_file('some non-existing file') # Second try: delete_file('some non-existing file')
Quite a bit of magic in this example! The side_effect
parameter tells mock to return those values in that sequence, making sure that the first call to os.path.exists
returns True
and the other two return False
. The mock.patch
without arguments simply returns a callable that does nothing.
The monkeypatch
object in py.test
is a fixture that allows mocking as well. While it may seem useless after seeing the possibilities with unittest.mock
, in summary, it's not. Some of the functionality does overlap, but while unittest.mock
focuses on controlling and recording the actions of an object, the monkeypatch
fixture focuses on simple and temporary environmental changes. Some examples of these are given in the following list:
monkeypatch.setattr
and monkeypatch.delattr
monkeypatch.setitem
and monkeypatch.delitem
monkeypatch.setenv
and monkeypatch.delenv
sys.path
before all others using monkeypatch.syspath_prepend
monkeypatch.chdir
To undo all modifications, simply use monkeypatch.undo
.
For example, let's say that for a certain test, we need to work from a different directory. With mock, your options would be to mock pretty much all file functions, including the os.path
functions, and even in that case, you will probably forget about a few. So, it's definitely not useful in this case. Another option would be to put the entire test into a try…finally
block and just do an os.chdir
before and after the testing code. This is quite a good and safe solution, but it's a bit of extra work, so let's compare the two methods:
import os def test_chdir_monkeypatch(monkeypatch): monkeypatch.chdir('/dev') assert os.getcwd() == '/dev' monkeypatch.chdir('/') assert os.getcwd() == '/' def test_chdir(): original_directory = os.getcwd() try: os.chdir('/dev') assert os.getcwd() == '/dev' os.chdir('/') assert os.getcwd() == '/' finally: os.chdir(original_directory)
They effectively do the same, but one needs four lines of code whereas the other needs eight. All of these can easily be worked around with a few extra lines of code, of course, but the simpler the code is, the fewer mistakes you can make and the more readable it is.