Motivation
Sometimes we need to test code, but we cannot run it from anywhere – for instance, a call to the subprocess
module invoking sudo
won’t run on a Github CI instance –, or not too often – for instance, a call to a web API with query limits. In this case, the mock
module from unittest
can be used to imitate these calls and verify they are used by our code as intended.
In this post, I summarise some of my lessons learned from developing the sirup
package, which relies on system calls to OpenVPN
. I only cover a few technical aspects; see some resources at the end of the post.
%xmode Minimal
Exception reporting mode: Minimal
from unittest import mock
import subprocess
Simple example
output = subprocess.run(["ls", "-lh"])
vars(output)
total 52K
-rw-rw-r-- 1 flavio flavio 21 jan 12 14:51 requirements.txt
-rw-rw-r-- 1 flavio flavio 18K jan 12 15:31 tutorial-mocking.ipynb
-rw-rw-r-- 1 flavio flavio 25K jan 12 14:19 tutorial-mocking.md
{'args': ['ls', '-lh'], 'returncode': 0, 'stdout': None, 'stderr': None}
def list_files(path, pwd):
"Use sudo to list files in `path`."
cmd = ["sudo", "-S", "ls", "-lh", path]
output = subprocess.run(cmd, input=pwd.encode())
return output
@mock.patch("subprocess.run") #the decorators are passed as first arguments to the function
def test_list_files(mock_subprocess_run, fail=False):
if not fail:
output = list_files("mypath", "my_password")
mock_subprocess_run.assert_called_once()
test_list_files()
test_list_files(fail=True)
AssertionError: Expected 'run' to have been called once. Called 0 times.
Instead of assert_called_once()
, we can use assert_called_once_with
to check precisely which arguments were used to call the function
def list_files(path, pwd):
"Use sudo to list files in `path`."
cmd = ["sudo", "-S", "ls", "-lh", path]
output = subprocess.run(cmd, input=pwd.encode())
return output
@mock.patch("subprocess.run")
def test_list_files(mock_subprocess_run, fail=False):
output = list_files("mypath", "my_password")
cmd_expected = ["sudo", "-S", "ls", "-lh"]
if fail:
cmd_expected += ["anotherpath"]
else:
cmd_expected += ["mypath"]
mock_subprocess_run.assert_called_once_with(cmd_expected, input="my_password".encode())
test_list_files()
test_list_files(fail=True)
AssertionError: expected call not found.
Expected: run(['sudo', '-S', 'ls', '-lh', 'anotherpath'], input=b'my_password')
Actual: run(['sudo', '-S', 'ls', '-lh', 'mypath'], input=b'my_password')
What does the mock do?
First, we patch the subprocess.run
function and it is passed to the test function. The test function operates on that patch instead of actually calling subprocess.run
. Note that there are other ways to patch. But, the patched object records the calls made to the function, and this is the key functionality of mocking because we can assert that it was called as intended.
Pitfall: being ambiguous
It can be dangerous if the assertions are not specific enough, as this example illustrates:
def list_files(path, pwd):
"Use sudo to list files in `path`."
cmd = ["sudo", "-S", "ls", "-lh", path]
output = subprocess.run(cmd, input=pwd.encode())
return output
@mock.patch("subprocess.run")
def test_list_files(mock_subprocess_run, fail=False):
output = list_files("anotherpath", "my_password")
cmd_expected = ["sudo", "-S", "ls", "-lh", "mypath"]
if fail:
mock_subprocess_run.assert_called_once_with(cmd_expected)
else:
mock_subprocess_run.assert_called_once()
test_list_files(fail=True)
AssertionError: expected call not found.
Expected: run(['sudo', '-S', 'ls', '-lh', 'mypath'])
Actual: run(['sudo', '-S', 'ls', '-lh', 'anotherpath'], input=b'my_password')
test_list_files(fail=False)
The test does not fail, even though the command was not executed exactly the way we expected. I think most of the times, it is better to directly test for the exact arguments used in the call.
Setting return values
Suppose we have a function that asks the user for the password with the getpass
module. Instead of actually asking for a password each time we call the test function, we can set a return value to the getpass.getpass
function, which is then further used
import getpass
def ask_for_pw_and_list_files(path):
"Use sudo to list files in `path`." # this function does not work in a noteobook I think
pwd = getpass.getpass("Enter your sudo password:")
cmd = ["sudo", "-S", "ls", "-lh", path]
output = subprocess.run(cmd, input=pwd.encode(), stdout=subprocess.PIPE)
return output
@mock.patch("subprocess.run")
@mock.patch("getpass.getpass")
def test_ask_for_pw_and_list_files(mock_getpass, mock_subprocess_run, fail=False): # note the order of the inputs
if not fail:
mock_getpass.return_value = "my_password"
cmd_expected = ["sudo", "-S", "ls", "-lh", "mypath"]
ask_for_pw_and_list_files("mypath")
mock_getpass.assert_called_once()
mock_subprocess_run.assert_called_once_with(cmd_expected, input="my_password".encode(), stdout=subprocess.PIPE)
test_ask_for_pw_and_list_files()
The next one fails because the getpass
mock is used to ask_for_pw_and_list_files
, but no return value is set, and so pwd
is not a string but the mock
object itself.
test_ask_for_pw_and_list_files(fail=True)
AssertionError: expected call not found.
Expected: run(['sudo', '-S', 'ls', '-lh', 'mypath'], input=b'my_password', stdout=-1)
Actual: run(['sudo', '-S', 'ls', '-lh', 'mypath'], input=<MagicMock name='getpass().encode()' id='139928887543632'>, stdout=-1)
Context managers/class methods
When patching context managers, we need to patch both the __enter__
method and the return code of the context manager. We can access class methods by using class.return_value.method
.
from subprocess import PIPE
def list_files_with_context(pwd, path):
"Use sudo to list files in `path`."
cmd = ["sudo", "-S", "ls", "-lh", path]
with subprocess.Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) as proc:
stdout, _ = proc.communicate(pwd.encode())
return stdout
@mock.patch("subprocess.Popen")
def test_list_files_with_context(mock_popen, type_context_mock):
cmd_expected = ["sudo", "-S", "ls", "-lh", "."]
# Note the two different ways of how we set the return value of `Popen`:
if type_context_mock == "return_enter_return":
process = mock_popen.return_value.__enter__.return_value
elif type_context_mock == "enter_return":
process = mock_popen.__enter__.return_value
print(type(process))
print(vars(process))
print(dir(process))
print(f"new parent: {process._mock_new_parent}")
# we need to set more things
process.communicate.return_value = (b"blah", b"")
# process.poll.return_value = None # this silences the CalledProcessError that shows up otherwise
# now we can call our function
out = list_files_with_context("my_password", ".")
mock_popen.assert_called_once_with(cmd_expected, stdin=PIPE, stdout=PIPE, stderr=PIPE)
process.communicate.assert_called_once_with("my_password".encode())
assert out == b"blah"
test_list_files_with_context(type_context_mock="enter_return")
<class 'unittest.mock.MagicMock'>
{'_mock_return_value': sentinel.DEFAULT, '_mock_parent': None, '_mock_name': None, '_mock_new_name': '()', '_mock_new_parent': <MagicMock name='Popen.__enter__' id='139928887920976'>, '_mock_sealed': False, '_spec_class': None, '_spec_set': None, '_spec_signature': None, '_mock_methods': None, '_spec_asyncs': [], '_mock_children': {}, '_mock_wraps': None, '_mock_delegate': None, '_mock_called': False, '_mock_call_args': None, '_mock_call_count': 0, '_mock_call_args_list': [], '_mock_mock_calls': [], 'method_calls': [], '_mock_unsafe': False, '_mock_side_effect': None}
['assert_any_call', 'assert_called', 'assert_called_once', 'assert_called_once_with', 'assert_called_with', 'assert_has_calls', 'assert_not_called', 'attach_mock', 'call_args', 'call_args_list', 'call_count', 'called', 'configure_mock', 'method_calls', 'mock_add_spec', 'mock_calls', 'reset_mock', 'return_value', 'side_effect']
new parent: <MagicMock name='Popen.__enter__' id='139928887920976'>
ValueError: not enough values to unpack (expected 2, got 0)
test_list_files_with_context(type_context_mock="return_enter_return")
<class 'unittest.mock.MagicMock'>
{'_mock_return_value': sentinel.DEFAULT, '_mock_parent': None, '_mock_name': None, '_mock_new_name': '()', '_mock_new_parent': <MagicMock name='Popen().__enter__' id='139929231216720'>, '_mock_sealed': False, '_spec_class': None, '_spec_set': None, '_spec_signature': None, '_mock_methods': None, '_spec_asyncs': [], '_mock_children': {}, '_mock_wraps': None, '_mock_delegate': None, '_mock_called': False, '_mock_call_args': None, '_mock_call_count': 0, '_mock_call_args_list': [], '_mock_mock_calls': [], 'method_calls': [], '_mock_unsafe': False, '_mock_side_effect': None}
['assert_any_call', 'assert_called', 'assert_called_once', 'assert_called_once_with', 'assert_called_with', 'assert_has_calls', 'assert_not_called', 'attach_mock', 'call_args', 'call_args_list', 'call_count', 'called', 'configure_mock', 'method_calls', 'mock_add_spec', 'mock_calls', 'reset_mock', 'return_value', 'side_effect']
new parent: <MagicMock name='Popen().__enter__' id='139929231216720'>
Conclusion
The mock
module is helpful when we cannot call a function directly in a test. It’s important to consider alternatives to mocking and patching, and if we use them, be careful about how we use them.
Resources
- A good video on when and how to (not) use mocking more generally, and possible alternatives, is this PyCon talk by Edwin Jung .
- This blog post by Ned Batchelder
discusses the
autospec
option and has further links.