- We want to test the behavior of a function that relies on some other unknown or dynamic thing (e.g., a user's home directory).
- We will set up a test double resource (a "fixture") that we want to re-use.
- We want to test the result of side effects for functions with no return value like a print operation to standard output.
We will test 2 functions get_home_dir and print_home_dir.
get_home_dirreturns a string. E.g.,'/Users/paul'on my machine.print_home_dirprints that value.
Path.home()is user-specific. We will inject behavior into thePathclasshome()method call through mocking.- This can be done using a context manager or decorator.
- Since we need to mock
Pathtwice, we can also create a fixture for code re-use. See Example #2. - For
print_home_dir- we want to capture standard output.pytesthas a built-in fixturecapsysthat we can use for this. - In Example #3, we just show another variation of using
unittest.mock.patchwith a decorator. autospec=True- it's generally good to useautospec=Trueby default.autospec=Truemakes your mock match the specification of class attributes and methods of the class being mocked. Otherwise, your mock class may accept invalid methods. This leaves your test being susceptible to bugs like typos in method calls. These invalid method calls could pass your tests inadvertently.
- https://docs.python.org/3/library/unittest.mock.html#
- https://docs.pytest.org/en/7.4.x/contents.html
fixture- In unittest, you typically use setUp and tearDown methods to set up a fixed environment ("fixture") for each test case to run in.
- In pytest, fixtures are more flexible and can be created using the
@pytest.fixturedecorator. They are functions that return a resource and can be injected into test functions as arguments.
mock- Mocks are objects that mimic the behavior of real objects in a controlled way. Theunittestlibrary has aMockclass in itsunittest.mockmodule for creating mock objects. Mocks can be configured to return specific values when invoked, allowing you to isolate the code under test from external dependencies.monkeypatch- this is distinct fromunittest.mock.patch- In
unittest, Monkey patching generally refers to dynamically modifying or extending the behavior of classes or modules at runtime. In unittest, you might use the unittest.mock library to replace attributes temporarily. Monkeypatching is simpler and more limited in scope compared to mocking. - Monkey patching vs mocking - You can set attributes with
monkeypatchbut you need mocking to mock more complex behavior like return values and side effects. - In
pytest, Pytest has a built-in monkeypatch fixture, which allows you to modify classes, functions, dictionaries, and more.
- In
Here are the functions that we will test.
"""home_dir.py
"""
from pathlib import Path
def get_home_dir() -> str:
"""Gets user home directory. Ex: "/Users/paul"."""
return str(Path.home())
def print_home_dir() -> None:
print(get_home_dir())
if __name__ == "__main__":
print_home_dir()What follows is the test code.
Using patch.object context managers. Note the repetition in both tests.
"""test_home_dir.py
"""
from pathlib import Path
from unittest.mock import patch
import home_dir
def test_get_home_dir() -> None:
with patch.object(Path, "home", return_value="test/test"):
assert home_dir.get_home_dir() == "test/test"
def test_print_home_dir(capsys) -> None:
with patch.object(Path, "home", return_value="test/test"):
home_dir.print_home_dir()
output = capsys.readouterr().out.strip()
assert output == "test/test"With user-created fixture.
Here we create our own mocked_home_path fixture for re-use on both tests. We pass mocked_home_path as an argument to the test functions (even though they're not called directly in the test functions). The mocked_home_path will be substituted for Path object instances.
"""test_home_dir.py
"""
from pathlib import Path
from unittest.mock import patch
import home_dir
import pytest
@pytest.fixture
def mocked_home_path():
with patch.object(Path, "home", return_value="test/test") as mocked_home_path:
yield mocked_home_path
def test_get_home_dir(mocked_home_path) -> None:
assert home_dir.get_home_dir() == "test/test"
def test_print_home_dir(capsys, mocked_home_path) -> None:
home_dir.print_home_dir()
output = capsys.readouterr().out.strip()
assert output == "test/test"Using patch decorator.
"""test_home_dir.py
"""
from unittest.mock import patch
import home_dir
@patch("home_dir.Path.home", return_value="test/test")
def test_get_home_dir(mocked_home_path) -> None:
assert home_dir.get_home_dir() == "test/test"
# patch passes the mock as first argument
# so ordering of ('mocked_home_path, capsys') arguments matter
@patch("home_dir.Path.home", return_value="test/test")
def test_print_home_dir(mocked_home_path, capsys) -> None:
home_dir.print_home_dir()
output = capsys.readouterr().out.strip()
assert output == "test/test"Subtle difference: using @patch.object instead of @patch.
"""test_home_dir.py
"""
from pathlib import Path
from unittest.mock import patch
import home_dir
import pytest
@patch.object(Path, "home", return_value="test/test", autospec=True)
def test_get_home_dir(mocked_home_path) -> None:
assert home_dir.get_home_dir() == "test/test"
# patch passes the mock as first argument
# so ordering of ('mocked_home_path, capsys') arguments matter
@patch.object(Path, "home", return_value="test/test", autospec=True)
def test_print_home_dir(mocked_home_path, capsys) -> None:
home_dir.print_home_dir()
output = capsys.readouterr().out.strip()
assert output == "test/test"- Be cautious not to over-use mocking.
- Mocks can be time-consuming, add complexity, and tend to test implementation rather than behavior.
- On the last point, this means tests are more likely to fail when the code changes even if the functionality has not change. This makes refactoring harder.
- That being said, mocking can help isolate parts of the system that you are testing.
- If you are mocking a commonly used system/service, chances are that there is already a library for it (cloud storage, database, HTTP requests). Check the web before rolling your own.