Перейти к содержимому
К программе курса
Pytest для джунов: Моки и интеграция
2 / 825%

unittest.mock: Mock, patch, MagicMock

25 минут

У вас есть функция send_welcome_email(user) которая вызывает SMTP-сервер. Как протестировать БЕЗ реальной отправки email?

Цель: Научиться создавать моки с unittest.mock и подменять зависимости в тестах.

Вы точно готовы?

Убедитесь, что прошли предыдущий урок:

  • ✅ Понимаете зачем нужны моки
  • ✅ Знаете разницу между mock/stub/fake
  • ✅ Знаете когда использовать мокирование

Если непонятно — вернитесь к Мокирование: зачем и когда.

Проблема: как подменить зависимость?

Код с внешней зависимостью

# src/email_service.py
 
import smtplib
 
def send_email(to, subject, body):
    """Отправляет email через SMTP"""
    server = smtplib.SMTP("smtp.gmail.com", 587)
    server.starttls()
    server.login("user@gmail.com", "password")
    server.sendmail("user@gmail.com", to, f"Subject: {subject}\n\n{body}")
    server.quit()
    return True

Как протестировать?

# ❌ Плохой тест — реально отправляет email!
def test_send_email():
    result = send_email("test@example.com", "Hi", "Hello!")
    assert result == True
    # Email отправлен на реальный адрес!

Проблемы:

  • ❌ Каждый запуск теста отправляет реальный email
  • ❌ Нужны реальные credentials
  • ❌ Медленно (сетевой запрос)

Что нужно: Подменить smtplib.SMTP на мок-объект.

Решение 1: Mock() — создание мок-объектов

Базовый Mock

from unittest.mock import Mock
 
# Создаём мок-объект
mock_server = Mock()
 
# Мок отвечает на ЛЮБОЙ вызов метода
mock_server.starttls()        # OK
mock_server.login("a", "b")   # OK
mock_server.sendmail()        # OK
mock_server.any_method()      # OK — мок принимает всё!
 
# Проверяем что методы были вызваны
mock_server.starttls.assert_called_once()
mock_server.login.assert_called_once_with("a", "b")

✅ Mock-объект записывает все вызовы и позволяет их проверять!

return_value: что возвращать

# Мок возвращает другой мок по умолчанию
mock = Mock()
result = mock.some_method()
print(result)  # <Mock name='mock.some_method()' id='...'>
 
# Установим конкретное значение
mock.some_method.return_value = 42
result = mock.some_method()
print(result)  # 42

Практический пример:

# src/weather_service.py
import requests
 
class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/?city={city}")
        data = response.json()
        return data["temperature"]
 
# tests/test_weather.py
from unittest.mock import Mock
 
def test_get_temperature():
    # Arrange: создаём мок requests
    mock_response = Mock()
    mock_response.json.return_value = {"temperature": 15}
 
    mock_requests = Mock()
    mock_requests.get.return_value = mock_response
 
    # Act: подменяем requests на мок (пока руками)
    service = WeatherService()
    # Как подменить requests? См. @patch ниже!

side_effect: динамическое поведение

# 1. Вызвать исключение
mock = Mock()
mock.some_method.side_effect = ValueError("Boom!")
 
mock.some_method()  # Выбросит ValueError
 
# 2. Вернуть разные значения при каждом вызове
mock.some_method.side_effect = [10, 20, 30]
 
print(mock.some_method())  # 10
print(mock.some_method())  # 20
print(mock.some_method())  # 30
 
# 3. Использовать функцию
def custom_logic(arg):
    return arg * 2
 
mock.some_method.side_effect = custom_logic
print(mock.some_method(5))   # 10
print(mock.some_method(10))  # 20

Практический пример:

# Мокируем API который падает с ошибкой
def test_api_timeout():
    mock_api = Mock()
    mock_api.fetch_data.side_effect = TimeoutError("API timeout")
 
    service = DataService(api=mock_api)
 
    with pytest.raises(TimeoutError):
        service.get_data()

Решение 2: @patch() — подмена зависимостей

Проблема: как подменить импорт

# src/weather.py
import requests  # Как подменить этот import?
 
def get_temperature(city):
    response = requests.get(f"https://api.weather.com/?city={city}")
    return response.json()["temperature"]

Решение: @patch()

# tests/test_weather.py
from unittest.mock import patch
 
@patch("src.weather.requests")  # Подменяем requests в модуле src.weather
def test_get_temperature(mock_requests):
    """Тест с подменой requests"""
    # Arrange: настраиваем мок
    mock_response = Mock()
    mock_response.json.return_value = {"temperature": 15}
    mock_requests.get.return_value = mock_response
 
    # Act: вызываем функцию
    temp = get_temperature("Moscow")
 
    # Assert
    assert temp == 15
    mock_requests.get.assert_called_once_with("https://api.weather.com/?city=Moscow")

@patch() автоматически подменяет requests на мок!

Правило: где патчить

Важно: Патчите ТАМ где объект ИСПОЛЬЗУЕТСЯ, не где определён!

# src/email_service.py
import smtplib  # Импорт здесь
 
def send_email(to, subject, body):
    server = smtplib.SMTP(...)  # Используется здесь
    # ...

Правильно:

# ✅ Патчим в модуле где используется
@patch("src.email_service.smtplib")
def test_send_email(mock_smtplib):
    pass

Неправильно:

# ❌ Патчим в модуле где определён
@patch("smtplib")  # Не сработает!
def test_send_email(mock_smtplib):
    pass

Множественный патчинг

@patch("src.service.requests")
@patch("src.service.open")
def test_service(mock_open, mock_requests):
    # Порядок аргументов ОБРАТНЫЙ порядку декораторов!
    # mock_open — последний декоратор (второй сверху)
    # mock_requests — первый декоратор (первый сверху)
    pass

Проще использовать контекстный менеджер:

def test_service():
    with patch("src.service.requests") as mock_requests, \
         patch("src.service.open") as mock_open:
 
        # Порядок аргументов естественный
        mock_requests.get.return_value = Mock(json=lambda: {"data": 123})
        mock_open.return_value = Mock()
        # Тест...

Практический пример: EmailService

# src/email_service.py
import smtplib
 
def send_email(to, subject, body):
    """Отправляет email через SMTP"""
    server = smtplib.SMTP("smtp.gmail.com", 587)
    server.starttls()
    server.login("user@gmail.com", "password")
    server.sendmail("user@gmail.com", to, f"Subject: {subject}\n\n{body}")
    server.quit()
    return True
 
# tests/test_email_service.py
from unittest.mock import patch, Mock
 
@patch("src.email_service.smtplib.SMTP")
def test_send_email(mock_smtp_class):
    """Тест отправки email БЕЗ реального SMTP"""
    # Arrange: настраиваем мок SMTP сервера
    mock_server = Mock()
    mock_smtp_class.return_value = mock_server  # SMTP(...) вернёт наш мок
 
    # Act: вызываем функцию
    result = send_email("test@example.com", "Hello", "Test email")
 
    # Assert: проверяем результат
    assert result == True
 
    # Assert: проверяем что SMTP вызван правильно
    mock_smtp_class.assert_called_once_with("smtp.gmail.com", 587)
    mock_server.starttls.assert_called_once()
    mock_server.login.assert_called_once_with("user@gmail.com", "password")
    mock_server.sendmail.assert_called_once()
    mock_server.quit.assert_called_once()

✅ Email НЕ отправлен, но мы проверили что код вызывает правильные методы!

Проверка вызовов (verification)

Базовые методы

mock = Mock()
mock.method(1, 2, key="value")
 
# Проверка что вызван хотя бы раз
mock.method.assert_called()
 
# Проверка что вызван РОВНО один раз
mock.method.assert_called_once()
 
# Проверка последнего вызова с конкретными аргументами
mock.method.assert_called_with(1, 2, key="value")
 
# Проверка что вызван РОВНО один раз с конкретными аргументами
mock.method.assert_called_once_with(1, 2, key="value")
 
# Проверка что НЕ вызван
mock.method.assert_not_called()

Продвинутые проверки

mock = Mock()
mock.method(1, 2)
mock.method(3, 4)
 
# Получить количество вызовов
print(mock.method.call_count)  # 2
 
# Получить все вызовы
print(mock.method.call_args_list)
# [call(1, 2), call(3, 4)]
 
# Проверить любой вызов (ANY)
from unittest.mock import ANY
 
mock.method.assert_called_with(1, ANY)  # Второй аргумент любой

Практический пример

@patch("src.logger.Logger")
def test_service_logs_errors(mock_logger_class):
    """Проверяем что сервис логирует ошибки"""
    mock_logger = Mock()
    mock_logger_class.return_value = mock_logger
 
    service = Service()
    service.process_invalid_data({"bad": "data"})
 
    # Проверяем что logger.error был вызван
    mock_logger.error.assert_called_once()
 
    # Проверяем сообщение об ошибке
    call_args = mock_logger.error.call_args
    message = call_args[0][0]  # Первый позиционный аргумент
    assert "Invalid data" in message

MagicMock: для магических методов (bonus)

Проблема с обычным Mock

mock = Mock()
 
# Магические методы не работают!
len(mock)  # TypeError
mock[0]    # TypeError

Решение: MagicMock

from unittest.mock import MagicMock
 
mock = MagicMock()
 
# Магические методы работают!
mock.__len__.return_value = 5
print(len(mock))  # 5
 
mock.__getitem__.return_value = "value"
print(mock[0])  # "value"

Практический пример:

@patch("src.service.open", MagicMock())
def test_read_file(mock_open):
    """Тест чтения файла"""
    # Настраиваем мок файла с контекстным менеджером
    mock_file = MagicMock()
    mock_file.__enter__.return_value.read.return_value = "file content"
    mock_open.return_value = mock_file
 
    # Код использует: with open(...) as f: content = f.read()
    content = read_config_file("config.txt")
 
    assert content == "file content"

Когда использовать:

  • Mock() — в 95% случаев (достаточно)
  • MagicMock() — когда нужны __len__, __getitem__, __enter__ и т.д.

Практический пример: полный тест

# src/order_service.py
import requests
from datetime import datetime
 
class OrderService:
    def __init__(self, api_url):
        self.api_url = api_url
 
    def create_order(self, user_id, items):
        """Создаёт заказ через API"""
        # 1. Получаем данные пользователя
        response = requests.get(f"{self.api_url}/users/{user_id}")
        user = response.json()
 
        # 2. Создаём заказ
        order_data = {
            "user": user,
            "items": items,
            "created_at": datetime.now().isoformat()
        }
 
        response = requests.post(f"{self.api_url}/orders", json=order_data)
        return response.json()
 
# tests/test_order_service.py
from unittest.mock import patch, Mock
import pytest
 
@patch("src.order_service.datetime")
@patch("src.order_service.requests")
def test_create_order(mock_requests, mock_datetime):
    """Тест создания заказа БЕЗ реального API"""
    # Arrange: мокируем requests.get (получение пользователя)
    mock_user_response = Mock()
    mock_user_response.json.return_value = {
        "id": 1,
        "name": "Alice",
        "email": "alice@example.com"
    }
 
    # Arrange: мокируем requests.post (создание заказа)
    mock_order_response = Mock()
    mock_order_response.json.return_value = {
        "id": 101,
        "status": "created"
    }
 
    # requests.get/post возвращают разные моки
    mock_requests.get.return_value = mock_user_response
    mock_requests.post.return_value = mock_order_response
 
    # Arrange: мокируем datetime.now()
    mock_datetime.now.return_value.isoformat.return_value = "2025-01-15T10:00:00"
 
    # Act: создаём заказ
    service = OrderService(api_url="https://api.example.com")
    order = service.create_order(user_id=1, items=["item1", "item2"])
 
    # Assert: проверяем результат
    assert order["id"] == 101
    assert order["status"] == "created"
 
    # Assert: проверяем что API вызван правильно
    mock_requests.get.assert_called_once_with("https://api.example.com/users/1")
    mock_requests.post.assert_called_once()
 
    # Assert: проверяем данные отправленные в POST
    call_args = mock_requests.post.call_args
    sent_data = call_args[1]["json"]  # Именованный аргумент 'json'
    assert sent_data["user"]["name"] == "Alice"
    assert sent_data["items"] == ["item1", "item2"]
    assert sent_data["created_at"] == "2025-01-15T10:00:00"

✅ Тест работает мгновенно БЕЗ реального API!

Что вы изучили

  • Mock() — создание мок-объектов
  • return_value — что возвращать при вызове
  • side_effect — динамическое поведение (исключения, последовательности)
  • @patch() — подмена зависимостей в тестах
  • Verification — assert_called_once_with(), call_count, call_args
  • MagicMock — для магических методов (len, getitem)

Следующий урок

Отлично! Теперь вы умеете создавать моки и патчить зависимости. Но работать с HTTP-запросами через patch("requests") неудобно. Есть специализированные библиотеки!

Переходите к уроку 2: Мокирование HTTP: requests-mock

В следующем уроке вы узнаете:

  • requests-mock — удобное мокирование HTTP
  • Мокирование GET/POST/PUT/DELETE запросов
  • Проверка headers, query params, JSON body
  • Симуляция ошибок (timeout, 404, 500)

Устранение неисправностей

Проверьте что:

  1. Патчите правильное место: @patch("src.module.dependency")
  2. Мок настроен ДО вызова функции
  3. Функция действительно вызывает этот метод

Порядок аргументов ОБРАТНЫЙ порядку декораторов:

@patch("module.dep1")  # Второй аргумент
@patch("module.dep2")  # Первый аргумент
def test(mock_dep2, mock_dep1):
  pass

Или используйте with patch(...) as mock: для понятного порядка.

Mock принимает ЛЮБОЙ вызов. Эта ошибка значит что вы обращаетесь к атрибуту мока ДО его вызова:

mock.method.return_value = 42  # ✅ OK
print(mock.result)             # ❌ Mock не знает что это

Слишком много моков! Добавьте integration-тесты без моков чтобы проверить реальное взаимодействие компонентов.

unittest.mock: Mock, patch, MagicMock — Pytest для джунов: Моки и интеграция — Potapov.me