Mocking/shaare/G_SN4g
Mocking Fundamentals
Introduction
- When unit testing DevOps scripts that interact with external systems, tests can become slow, unreliable, difficult to set up, or even destructive.
- Mocking replaces these real dependencies with controlled, fake objects so that tests run quickly and deterministically.
- Python’s built-in
unittest.mockmodule provides tools to create and configure these mock objects and to track interactions.
What is Mocking?
- Mocking involves creating objects that mimic the behavior of real functions or classes in a controlled environment.
- When your code calls a mocked object, you can specify what it returns, simulate exceptions, or inspect how it was called.
- This allows you to isolate the logic under test and avoid side effects from actual external calls.
Using unittest.mock.patch
- The
patchfunction replaces a target object with a mock in a specified scope, either for the duration of a function (decorator) or within a context block (with). - As a decorator,
patchinjects the mock into the test function’s parameters; as a context manager, it yields the mock within thewithblock. - It’s important to patch the object where it is looked up in the module under test, not necessarily where it is originally defined.
MagicMock and Configuring Mock Objects
- When you patch an object, you typically receive a
MagicMockinstance that you can configure. - Use
mock.return_valueto define what the mock will return when called. - Use
mock.side_effectto simulate an exception being raised by the mock when invoked, to pass different values to be returned by each execution, or to pass a calable to replace the implemented function. - Assertion methods like
assert_called_withandassert_called_oncelet you verify interactions with the mock.
Common Mocking Scenarios in DevOps
- Network API Calls: Mock
requests.getorrequests.postto simulate successful responses, HTTP errors, or timeouts. - Filesystem Operations: Mock functions like
open()oros.path.exists()to simulate file presence or content. - Subprocess Execution: Mock
subprocess.runto avoid running real system commands and control return codes. - Time-Dependent Code: Patch
time.sleepor mockdatetime.now()to remove delays and make time-based tests deterministic.
from unittest.mock import patch, Mock
from pytest_mock import MockerFixture
from dummy_functions import check_file_exists, get_user_data
# Section: Using unittest.mock.patch
def test_check_file_exists_manual_patch() -> None:
filepath = "/path/to/some/file.txt"
patcher = patch("dummy_functions.os.path.exists")
mock_exists = patcher.start()
mock_exists.return_value = True
try:
result = check_file_exists(filepath=filepath)
mock_exists.assert_called_once_with(filepath)
assert result is True
finally:
patcher.stop()
def test_check_file_exists_context_manager() -> None:
filepath = "/path/to/some/file.txt"
with patch("dummy_functions.os.path.exists") as mock_exists:
mock_exists.return_value = True
result = check_file_exists(filepath=filepath)
mock_exists.assert_called_once_with(filepath)
assert result is True
@patch("dummy_functions.os.path.exists")
def test_check_file_exists_decorator(mock_exists: Mock) -> None:
filepath = "/path/to/some/file.txt"
mock_exists.return_value = True
result = check_file_exists(filepath=filepath)
mock_exists.assert_called_once_with(filepath)
assert result is True
def test_check_file_pytest_mocker(mocker: MockerFixture) -> None:
filepath = "/path/to/some/file.txt"
mock_exists = mocker.patch("dummy_functions.os.path.exists")
mock_exists.return_value = True
result = check_file_exists(filepath=filepath)
mock_exists.assert_called_once_with(filepath)
assert result is True
# Section: MagicMock and Configuring Mock Objects
def test_get_user_data_success(mocker: MockerFixture) -> None:
mock_api_response: dict[str, str | int] = {
"id": 1,
"name": "test user",
}
mock_get = mocker.patch("dummy_functions.requests.get")
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = mock_api_response
data = get_user_data(user_id=1)
mock_get.assert_called_once_with(
"https://api.example.com/users/1"
)
assert data == mock_api_response
Example of dummy_function.py
import requests
import os
import subprocess
from typing import Optional, Any
def get_user_data(user_id: str | int) -> dict[str, str | int]:
response = requests.get(
f"https://api.example.com/users/{user_id}"
)
print(f"Status code: {response.status_code}")
response.raise_for_status()
return response.json()
def check_file_exists(filepath: str | os.PathLike[str]) -> bool:
return os.path.exists(filepath)
def get_external_ip():
"""Fetches the current external IP from an external service."""
try:
response = requests.get(
"https://api.ipify.org?format=json", timeout=5
)
response.raise_for_status()
return response.json().get("ip")
except requests.exceptions.RequestException:
return None
def get_current_user() -> Optional[str]:
try:
result = subprocess.run(
["whoami"],
capture_output=True,
text=True,
check=True,
timeout=5,
)
return result.stdout.strip()
except (
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
FileNotFoundError,
):
return None
def fetch_both_endpoints() -> (
tuple[dict[str, Any], dict[str, Any]]
):
"""
Fetch data from two endpoints and return their JSON responses as a tuple.
"""
response2 = requests.get("https://api.example.com/second")
response2.raise_for_status()
data2 = response2.json()
response1 = requests.get("https://api.example.com/first")
response1.raise_for_status()
data1 = response1.json()
return data1, data2
Advanced Mocking Concepts
Using side_effect
- The
side_effectattribute on a mock allows you to control its behavior beyond a single return value. - List of values: When
side_effectis set to a list, each call to the mock returns the next item in that list, in order. - Callable: When
side_effectis a function, it is called with the same arguments as the mock, and its return value is used as the mock’s return. - Exception: When
side_effectis an exception, it will raise that exception when the original function is called. - Use a list when you know the sequence and order of calls; use a function when behavior should vary based on arguments.
Choosing between Mock and MagicMock
Mock: A simple replacement that only creates attributes when accessed, and raises errors for undefined methods or attributes.MagicMock: Inherits fromMockand implements Python’s magic methods (__len__,__enter__, etc.) by default.- Use
Mockby default for stubbing external dependencies to catch unintended interactions. - Use
MagicMockonly when mocking objects that require special behavior, such as context managers or iterables.
import subprocess
import pytest
from unittest.mock import MagicMock
from pytest_mock import MockerFixture
from dummy_functions import (
get_current_user,
check_file_exists,
fetch_both_endpoints,
)
# Section: Using side_effect - Exceptions
def test_get_current_user_command_fails(mocker: MockerFixture):
mock_run = mocker.patch("dummy_functions.subprocess.run")
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1, cmd=["whoami"]
)
result = get_current_user()
assert result is None
# Section: Using side_effect - List for Multiple Calls
def test_check_file_exists_side_effect_list(
mocker: MockerFixture,
):
mock_exists = mocker.patch(
"dummy_functions.os.path.exists",
side_effect=[True, False],
)
assert check_file_exists("some/path/one") is True
assert check_file_exists("some/path/two") is False
assert mock_exists.call_count == 2
assert [
call.args for call in mock_exists.call_args_list
] == [("some/path/one",), ("some/path/two",)]
# Section: Using side_effect - Callable for Multiple Calls
def test_fetch_both_endpoints_by_url(mocker: MockerFixture):
fake_responses: dict[str, MagicMock] = {}
for url, data in [
("https://api.example.com/first", {"first": "data"}),
("https://api.example.com/second", {"second": "data"}),
]:
resp = mocker.MagicMock()
resp.status_code = 200
resp.json.return_value = data
fake_responses[url] = resp
def _fake_get(url: str) -> MagicMock:
return fake_responses[url]
mocker.patch(
"dummy_functions.requests.get", side_effect=_fake_get
)
result = fetch_both_endpoints()
assert result == ({"first": "data"}, {"second": "data"})
# Section: Choosing between Mock and MagicMock
@pytest.mark.xfail(
reason="Context managers do not work with Mock", strict=True
)
def test_context_manager_with_mock(mocker: MockerFixture):
fake_cm = mocker.Mock()
fake_cm.__enter__.return_value = fake_cm
fake_cm.read.return_value = "file contents"
mock_open = mocker.patch("builtins.open")
mock_open.return_value = fake_cm
with open("somefile.txt") as f:
contents = f.read()
mock_open.assert_called_once_with("somefile.txt")
assert contents == "file contents"
def test_context_manager_with_magicmock(mocker: MockerFixture):
fake_cm = mocker.MagicMock()
fake_cm.__enter__.return_value = fake_cm
fake_cm.read.return_value = "file contents"
mock_open = mocker.patch("builtins.open")
mock_open.return_value = fake_cm
with open("somefile.txt") as f:
contents = f.read()
mock_open.assert_called_once_with("somefile.txt")
assert contents == "file contents"
(97)