Mocking: unittest.mock, patch, side_effect

В реальном коде функции зависят от внешних систем - HTTP API, БД, файлов, времени. Тестировать с реальными зависимостями медленно, ненадёжно, требует setup. Решение - mocking: заменить зависимости тестовыми двойниками которые ведут себя предсказуемо. В этом уроке - unittest.mock, patch, side_effect и pytest-mock, который отдаёт mock через фикстуру mocker.

Зачем mocking

# Без mocking - тест зависит от реального API
def test_get_weather():
    weather = get_weather("Moscow")   # реальный HTTP запрос!
    assert weather["temp"] is not None

Проблемы:

  • Медленно (network roundtrip)
  • Нестабильно (API может быть недоступен)
  • Зависит от внешнего state
  • Невозможно тестировать error cases (HTTP 500)
  • Не работает offline

С mocking:

def test_get_weather(mocker):
    mocker.patch("myapp.requests.get", return_value=Mock(json=lambda: {"temp": 20}))
    weather = get_weather("Moscow")
    assert weather["temp"] == 20

Быстро, надёжно, изолировано.

Mock и MagicMock

from unittest.mock import Mock, MagicMock

m = Mock()
m.attribute = 42
m.method.return_value = "result"

print(m.attribute)            # 42
print(m.method())             # result
print(m.anything)             # автоматически создаётся Mock
print(m.deep.nested.attr)     # тоже Mock

Mock автоматически создаёт attribute/method при доступе - не нужно объявлять заранее. Это «утиный» объект который притворяется чем угодно.

MagicMock - подкласс Mock с поддержкой magic methods (__len__, __iter__, __enter__, etc):

mm = MagicMock()
len(mm)        # работает (Mock - не работало бы)
for x in mm:   # работает - возвращает пустой iterator
    ...
with mm:       # работает как context manager
    ...

В 99% случаев нужен MagicMock. Mock используется когда хочешь явно ограничить функциональность.

return_value vs side_effect

mock = Mock()

# return_value - возвращает одно и то же при каждом вызове
mock.return_value = 42
mock()   # 42
mock()   # 42

# side_effect - функция или iterable
mock.side_effect = [1, 2, 3]   # последовательно
mock()   # 1
mock()   # 2
mock()   # 3
mock()   # StopIteration

# side_effect как функция
mock.side_effect = lambda x: x * 2
mock(5)   # 10
mock(10)  # 20

# side_effect как exception
mock.side_effect = ValueError("boom")
mock()   # ValueError

side_effect мощнее return_value:

  • Iterable - разные значения на разные вызовы
  • Function - dynamic поведение
  • Exception - моделирование errors

patch - подмена в коде

from unittest.mock import patch

# Подменим os.getenv в нашем коде на время теста
def get_config():
    return os.getenv("DEBUG", "0")

def test_get_config():
    with patch("os.getenv", return_value="1"):
        assert get_config() == "1"

patch это context manager или декоратор. Подменяет указанный объект на Mock на время теста, автоматически восстанавливает.

patch для импортированных функций

Главное правило: патчи там, где используется, не где определена.

# myapp/services.py
from external import fetch_data

def process():
    return fetch_data() + "_processed"

# tests/test_services.py
# ПРАВИЛЬНО - патчим в myapp.services где fetch_data импортирован
def test_process():
    with patch("myapp.services.fetch_data", return_value="data"):
        assert process() == "data_processed"

# НЕПРАВИЛЬНО - патчит в external, но myapp.services уже импортировал
def test_wrong():
    with patch("external.fetch_data", return_value="data"):
        assert process() == "data_processed"   # FAIL - использует оригинал

Это связано с тем как Python обрабатывает импорты. После from external import fetch_data в myapp.services есть локальная reference. Патч external не затрагивает её.

patch как декоратор

@patch("myapp.services.fetch_data")
def test_process(mock_fetch):
    mock_fetch.return_value = "data"
    assert process() == "data_processed"

Mock объект передаётся в test как аргумент. Полезно для нескольких patches:

@patch("myapp.services.db")
@patch("myapp.services.cache")
def test_process(mock_cache, mock_db):   # обратный порядок!
    ...

Порядок аргументов противоположен декораторам - bottom-up.

patch.object - патчим attribute класса

class API:
    def fetch(self):
        return real_api_call()

def test_api():
    api = API()
    with patch.object(api, "fetch", return_value="mocked"):
        assert api.fetch() == "mocked"

Удобно когда не хочется указывать full path к атрибуту.

patch.dict - патчим словарь

import os

def test_env():
    with patch.dict(os.environ, {"DEBUG": "1", "PORT": "8080"}):
        assert os.environ["DEBUG"] == "1"
        assert os.environ["PORT"] == "8080"
    # После выхода - оригинал восстановлен

Лучше чем monkeypatch.setenv если нужно установить много значений сразу.

pytest-mock - удобная обёртка

pip install pytest-mock

Даёт fixture mocker:

def test_process(mocker):
    mocker.patch("myapp.services.fetch_data", return_value="data")
    assert process() == "data_processed"

Преимущества mocker над patch:

  • Не нужен with statement или декоратор
  • Cleanup автоматически (как у monkeypatch)
  • Доступ ко всем patch методам: mocker.patch.object, mocker.patch.dict

Этот стиль предпочтительнее в pytest проектах.

Проверка вызовов

Mock записывает информацию о вызовах:

mock = Mock()
mock(1, 2, key="value")
mock(3)

# Был ли вызван
mock.called                    # True
mock.call_count                # 2

# Аргументы последнего вызова
mock.call_args                 # call(3)
mock.call_args.args            # (3,)

# Все вызовы
mock.call_args_list            # [call(1, 2, key='value'), call(3)]

# Assertions
mock.assert_called()           # был вызван хоть раз
mock.assert_called_once()      # ровно один раз
mock.assert_called_with(3)     # последний вызов с этими args
mock.assert_called_once_with(3)
mock.assert_any_call(1, 2)     # был ли где-то такой вызов
mock.assert_not_called()

Spec - ограничение mock интерфейса

class User:
    def __init__(self, name):
        self.name = name
    def greet(self):
        return f"Hi, {self.name}"

# Без spec - mock примет любой метод
mock_user = Mock()
mock_user.nonexistent()   # работает (создаёт автоматом)

# С spec - только атрибуты класса
mock_user = Mock(spec=User)
mock_user.greet()         # OK
mock_user.nonexistent()   # AttributeError

spec=Class или spec_set=Class (более строгий) предотвращают опечатки и тесты которые проверяют несуществующие методы. Рекомендуется всегда использовать spec для mocking custom классов.

autospec - даже сигнатуры методов

def fetch(url, timeout=30):
    ...

with patch("myapp.fetch", autospec=True) as mock_fetch:
    mock_fetch("https://api")           # OK
    mock_fetch("https://api", timeout=10)
    mock_fetch(invalid_arg=True)        # TypeError - нет такого аргумента

autospec проверяет сигнатуру при вызове - если вызов не совпадает с реальной функцией, ошибка. Защищает от incorrect mocking когда тест проходит но реальный код упадёт.

Mocking async functions

from unittest.mock import AsyncMock

async def fetch(url):
    ...

@pytest.mark.asyncio
async def test_async(mocker):
    mock_fetch = mocker.patch("myapp.fetch", new=AsyncMock(return_value="data"))
    result = await some_function_using_fetch()
    assert result == "processed_data"

AsyncMock автоматически возвращает coroutine. Появился в Python 3.8 для тестирования async-кода.

Mock context manager

mock_file = MagicMock()
mock_file.__enter__ = Mock(return_value=mock_file)
mock_file.__exit__ = Mock(return_value=None)
mock_file.read.return_value = "file content"

with patch("builtins.open", return_value=mock_file):
    with open("anything.txt") as f:
        data = f.read()
    assert data == "file content"

Или проще через mock_open:

from unittest.mock import mock_open

m = mock_open(read_data="file content")
with patch("builtins.open", m):
    with open("anything.txt") as f:
        data = f.read()
    assert data == "file content"

Тестирование HTTP - responses / respx

Для HTTP лучше использовать специализированные библиотеки:

# для requests
import responses

@responses.activate
def test_api():
    responses.add(responses.GET, "https://api.example.com/users",
                   json=[{"id": 1, "name": "Alice"}], status=200)
    users = fetch_users()
    assert len(users) == 1

# для httpx (async)
import respx

@respx.mock
def test_api():
    respx.get("https://api.example.com/users").mock(
        return_value=Response(200, json=[{"id": 1}])
    )
    users = await fetch_users()

Более expressive чем patch.requests.get и проверяют URL/method.

Когда mocking - анти-паттерн

1. Mocking чего-то простого

# Плохо - mock простой функции
def add(a, b):
    return a + b

def test_user_total(mocker):
    mocker.patch("myapp.add", return_value=10)
    user = User(balance=5)
    user.add_money(5)
    assert user.balance == 10   # тест проверяет mock, не реальную логику

Mock'ай только то что дорого или внешнее (network, БД, время).

2. Mocking implementation, не behavior

# Плохо - тест ломается при refactoring
def test_save(mocker):
    mocker.patch("user._validate")
    mocker.patch("user._normalize")
    mocker.patch("user._serialize")
    user.save()
    mocker.user._validate.assert_called()

Тестируй public API, не приватные методы.

3. Слишком сложные mocks

# Плохо - mock complex chain
mock = MagicMock()
mock.get_connection().cursor().execute().fetchall.return_value = ...

Это часто сигнал что тестируемый код плохо разделён - стоит refactor чтобы упростить тестирование.

Распространённые ошибки

1. Патч в wrong location

Уже обсуждали - патчи там, где используется.

2. Forget to verify mock was called

def test_send_email(mocker):
    mock_smtp = mocker.patch("smtplib.SMTP")
    send_welcome_email("alice@example.com")
    # Забыли проверить что mock был вызван!
    # Тест пройдёт даже если send_welcome_email ничего не делает

Всегда проверяй что mock реально вызвался:

mock_smtp.assert_called_once()
mock_smtp.return_value.sendmail.assert_called_once()

3. Mocking без spec

mock = Mock()
mock.deep_method_that_doesnt_exist()   # работает молча
# Тест пройдёт, но в production AttributeError

Используй spec= для защиты:

mock = Mock(spec=RealClass)

4. Side effects в side_effect

def cleanup():
    db.clean()   # SIDE EFFECT в side_effect!
mock.side_effect = cleanup

Side_effect должен быть pure - либо exception, либо возвращать значение. Side effects делают тесты непредсказуемыми.

5. Не использовать pytest-mock в pytest

# Старый стиль
def test_x():
    with patch("..."):
        ...

# pytest-style
def test_x(mocker):
    mocker.patch("...")
    ...

mocker fixture проще и идиоматичнее в pytest проектах.

Хорошие практики

1. Mock на границе системы (boundary)

# Тестируем business logic, mocking external API
def test_user_registration(mocker):
    mocker.patch("myapp.email_service.send")
    user = register_user("alice@example.com")
    assert user.status == "pending_verification"

2. Используй spec для безопасности

3. Verify вызовы Mock - используй assert_called_with

4. Предпочитай fakes/dependency injection для сложных случаев

# Вместо mock сложной БД
class FakeDB:
    def __init__(self):
        self.data = {}
    def save(self, item):
        self.data[item.id] = item
    def get(self, id):
        return self.data.get(id)

def test_with_fake_db():
    db = FakeDB()
    user = User("Alice")
    db.save(user)
    assert db.get(user.id).name == "Alice"

Fake это полноценная реализация для тестов - часто чище чем монолитный mock.

Сравнение с Go

В Go обычно используют interfaces для testability:

type DataSource interface {
    Fetch() string
}

type RealAPI struct{}
func (r RealAPI) Fetch() string { return realCall() }

type MockAPI struct {
    Response string
}
func (m MockAPI) Fetch() string { return m.Response }

func TestProcess(t *testing.T) {
    mock := MockAPI{Response: "data"}
    result := process(mock)
    if result != "data_processed" {
        t.Error("wrong result")
    }
}

Go более явный - mocking через interfaces, не runtime patching. Python через unittest.mock более flexible но менее compile-time safe. Go стиль ближе к dependency injection с явными interfaces.

Мини-задание

  1. Mock простой функции:
import requests
from unittest.mock import patch

def fetch_user(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

# Тест
def test_fetch_user():
    mock_response = type("Response", (), {"json": lambda self: {"id": 1, "name": "Alice"}})()
    with patch("requests.get", return_value=mock_response):
        user = fetch_user(1)
        assert user["name"] == "Alice"
  1. side_effect для разных вызовов:
from unittest.mock import Mock

mock = Mock()
mock.side_effect = ["first", "second", "third"]

print(mock())   # first
print(mock())   # second
print(mock())   # third
# print(mock())   # StopIteration
  1. С pytest-mock и spec:
import pytest

class DataAPI:
    def fetch(self, key):
        ...
    def save(self, key, value):
        ...

def test_data_processor(mocker):
    mock_api = mocker.Mock(spec=DataAPI)
    mock_api.fetch.return_value = "data"

    process_data(mock_api, "key1")

    mock_api.fetch.assert_called_once_with("key1")
    mock_api.save.assert_called_once_with("key1", "data_processed")

Что дальше

Освоили mocking. spec= особенно хорошо сочетается с Protocol: контракт описан явно, и mock проверяется по нему. В следующем уроке (последнем в Модуле 9) - линтеры и type checkers: ruff, black, mypy и pre-commit hooks для качества кода.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.