Моки и заглушки: изоляция тестов

Реальный код часто общается с внешним миром: HTTP-запросы, БД, файлы, время. В тестах это плохо: сеть может упасть, БД может быть медленной, время неуправляемо. Тест должен проверять вашу логику, а не работоспособность чужих сервисов.

Решение — тестовый двойник: подсовываем коду фейковый объект, который ведёт себя как нужно. У этого двойника есть две роли:

  • Stub (заглушка) — возвращает заранее заданные ответы, проверять ничего не нужно.
  • Mock — то же самое плюс умеет фиксировать вызовы (с какими аргументами и сколько раз), чтобы тест мог это проверить.

На практике в Python оба делаются одним инструментом: unittest.mock.

Иллюстрация: тест в центре, реальный payment_service справа перечёркнут красным (медленный, ненадёжный), Mock слева с зелёной галочкой и charge.return_value = True; стрелка от теста к Mock, сверху @patch

Mock-объект

Простейший случай: создаём Mock и настраиваем, что должны вернуть его методы.

Python 3.13
from unittest.mock import Mock

def process_payment(service, amount):
    if service.charge(amount):
        service.log(f"charged {amount}")
        return "OK"
    return "FAIL"

def test_payment_success():
    service = Mock()
    service.charge.return_value = True       # настроили мок

    result = process_payment(service, 100)

    assert result == "OK"
    service.charge.assert_called_once_with(100)
    service.log.assert_called_once_with("charged 100")

Функция process_payment принимает service аргументом. В production-коде туда передают реальный платёжный сервис; в тесте мы подсовываем Mock через тот же аргумент. В этом и смысл «зависимость через параметр». Что происходит дальше:

  • Mock() создаёт объект, у которого любой атрибут или метод существует автоматически.
  • service.charge.return_value = True говорит «когда тест вызовет service.charge(...), верни True».
  • assert_called_once_with(100) проверяет: «метод charge был вызван ровно один раз с аргументом 100». Это и есть отличие Mock от Stub: тест не просто получает данные, он проверяет как код взаимодействовал с зависимостью.

patch: подменить уже существующий объект

Mock() хорош, если зависимость передаётся в функцию аргументом. Но часто код использует импортированный объект напрямую (например, requests.get). Тогда нужен patch: он временно подменяет что-то в коде на мок:

Python 3.13
from unittest.mock import patch, Mock

# тестируемый код
import requests

def get_user(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    if response.status_code == 200:
        return response.json()
    return None

# тест
@patch("requests.get")
def test_get_user(mock_get):
    # настраиваем, что вернёт requests.get(...)
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"id": 1, "name": "Anna"}
    mock_get.return_value = mock_response

    user = get_user(1)

    assert user == {"id": 1, "name": "Anna"}
    mock_get.assert_called_once_with("https://api.example.com/users/1")

Что важно:

  • @patch("requests.get") подменяет requests.get на мок только на время теста, после теста всё возвращается обратно.
  • Аргумент mock_get (имя любое) — это автоматически созданный мок, который заменил оригинал. Через него настраиваем поведение и проверяем вызовы.
  • response.json() это метод (с круглыми скобками), поэтому настраиваем mock_response.json.return_value — у вложенного мока json свой собственный return_value. Любой атрибут или метод мока тоже мок, по цепочке.

patch можно использовать и как контекстный менеджер (with patch(...) as mock_get:) — пригодится, когда подмена нужна только для части теста.

Главная грабля: куда указывать путь в patch

Самая частая ошибка с patch это выбор пути. Правило: патчить там, где объект используется, а не там, где он определён.

Допустим, наш get_user живёт в файле app.py. Если код импортирует модуль целиком:

Python 3.13
# app.py
import requests

def get_user(user_id):
    return requests.get(f"https://api.example.com/users/{user_id}")

То в тесте патчим "requests.get", и это работает, потому что в app.py обращение идёт через сам модуль requests:

Python 3.13
@patch("requests.get")   # OK
def test_get_user(mock_get):
    ...

А если в коде использован from requests import get:

Python 3.13
# app.py
from requests import get

def get_user(user_id):
    return get(f"https://api.example.com/users/{user_id}")

То патчить "requests.get" бесполезно. После from ... import в app.py появилась локальная ссылка get, которая указывает на оригинальную функцию. Подмена в модуле requests её не касается. Патчить нужно по месту использования:

Python 3.13
@patch("app.get")    # патчим там, где get вызывается
def test_get_user(mock_get):
    ...

Мнемоника: «patch where it's looked up», куда тестируемый код смотрит за функцией, там и патчите.

side_effect: имитация ошибок

Иногда нужно проверить, что код корректно реагирует на сбой зависимости (например, на ConnectionError). Для этого у мока есть side_effect:

Python 3.13
from unittest.mock import Mock

mock_api = Mock()
mock_api.connect.side_effect = ConnectionError("network down")

# теперь mock_api.connect() выбросит ConnectionError

Этот же side_effect принимает функцию или список значений (для разных ответов на последовательные вызовы) — но это уже более редкие случаи.

Главное правило

Моки удобны, но коварны: легко начать мокать внутренности собственного кода, и тогда тесты проверяют не поведение, а реализацию. Любой рефакторинг ломает их, хотя код работает.

Правило: мокайте границы системы — внешние API, БД, файловую систему, время. Свой код тестируйте напрямую, без моков.

Проверка понимания

Что верно про моки и заглушки в Python?


В следующей статье — встроенный модуль unittest: классический xUnit-стиль с классами TestCase и методами setUp/tearDown. Это альтернатива pytest, которую вы встретите в legacy-коде.