## Проблема, которую решает мокирование
Начнём с простой функции, получающей текущую температуру для города:
```python
# weather.py
import requests
def get_temperature(city):
resp = requests.get(f'https://api.weather.example.com/temp/{city}')
return resp.json()['temperature']
```
Напишем тест:
```python
def test_get_temperature():
result = get_temperature('Kyiv')
assert result == 22
```
У этого теста серьёзные проблемы:
- **Падает при отсутствии интернета** -- даже если ваш код абсолютно верен
- **Падает при изменении данных API** -- `{'temperature': 22}` сегодня, `{'temp': 22}` завтра
- **Нельзя протестировать путь ошибки** -- как заставить API вернуть ошибку 500 по требованию?
- **Медленно** -- реальный HTTP-вызов при каждом запуске тестов добавляет секунды
- **Непредсказуемо** -- температура меняется; нельзя написать `assert result == 22`
Тест проверяет не *ваш* код -- он тестирует API погоды, сеть и интернет-соединение одновременно.
---
## Ключевая идея: контролируемый заменитель
**Мок** -- это объект, имитирующий реальную зависимость, но полностью находящийся под вашим контролем. Вместо того чтобы ваш код обращался к реальному API, он вызывает подставной объект, который вы заранее запрограммировали возвращать именно те данные, которые нужны.
Представьте это как контролируемый эксперимент. В химии не проверяют действие таблетки, давая её случайным людям в разных условиях -- контролируют каждую переменную, чтобы измерить ровно одну вещь. Мок делает то же самое: убирает все переменные внешней зависимости, и тест измеряет только ваш код.
```
Реальный тест (плохо):
ваш код -> requests -> интернет -> реальный сервер -> реальные данные -> ваш код
^ любой из них может упасть непредсказуемо
Тест с моком (хорошо):
ваш код -> мок -> ваши данные
^ всегда быстро, всегда возвращает то, что вы сказали, всегда доступно
```
---
## Шаг 1 -- MagicMock: ваш первый фиктивный объект
`MagicMock` -- это строительный блок. Объект, который:
- Принимает **любой вызов метода**, не выбрасывая `AttributeError`
- По умолчанию возвращает ещё один `MagicMock` из каждого вызова
- **Записывает каждое взаимодействие**, чтобы потом можно было проверить, что происходило
```python
from unittest.mock import MagicMock
mock = MagicMock()
# Обращение к любому атрибуту -- он просто создаётся автоматически:
print(mock.name) # <MagicMock name='mock.name' id='...'>
print(mock.does_not_exist) # нет AttributeError -- создаётся автоматически
# Вызов любого метода -- то же самое:
result = mock.calculate(10, 5) # не выбрасывает; возвращает ещё один MagicMock
```
Это поведение "создать всё автоматически" -- вот почему он называется *Magic*Mock. Никогда не нужно заранее объявлять, какие атрибуты или методы существуют.
### Установка возвращаемых значений
Используйте `return_value`, чтобы контролировать, что метод возвращает при вызове:
```python
mock = MagicMock()
mock.calculate.return_value = 42
result = mock.calculate(10, 5)
print(result) # 42
```
### Инспекция произошедшего (запись)
После вызова методов на моке можно узнать точно, что произошло:
```python
mock = MagicMock()
mock.send('hello', recipient='alice')
# Был ли вызов вообще?
mock.send.assert_called() # проходит
# Был ли вызов ровно один раз?
mock.send.assert_called_once() # проходит
# Был ли вызов с этими точными аргументами?
mock.send.assert_called_once_with('hello', recipient='alice') # проходит
# Что если проверить неверные аргументы?
mock.send.assert_called_once_with('wrong') # AssertionError!
# Сколько раз всего?
print(mock.send.call_count) # 1
```
Запись -- вот что делает моки полезными как *тест-шпионы*: вы не только контролируете, что возвращает зависимость, но и можете проверить, что ваш код вызвал её правильно.
---
## Шаг 2 -- Почему одного MagicMock недостаточно
Можно создать идеальный фиктивный объект, но есть проблема: ваш код уже импортировал `requests` и держит ссылку на него. Создание нового `MagicMock()` в тесте никак не влияет на то, что вызывает ваша функция.
```python
# Это НЕ работает:
def test_get_temperature_broken():
fake_requests = MagicMock()
fake_requests.get.return_value.json.return_value = {'temperature': 22}
result = get_temperature('Kyiv') # всё равно вызывает НАСТОЯЩИЙ requests.get!
assert result == 22
```
Почему? Потому что `weather.py` выполнил `import requests` при первой загрузке. Имя `requests` внутри `weather.py` привязано к реальному модулю `requests`. Переменная `fake_requests` в тесте -- просто локальная переменная, не имеющая никакого отношения к тому, что использует `weather.py`.
Чтобы `get_temperature` использовала ваш фиктивный объект, нужно заменить имя `requests` *внутри* модуля `weather`. Именно это делает `patch`.
---
## Шаг 3 -- patch: внедрение мока в нужное место
`patch` временно заменяет имя в пространстве имён модуля на `MagicMock` (или любой объект по выбору), выполняет тест, затем восстанавливает оригинал:
```python
from unittest.mock import patch
with patch('weather.requests') as mock_requests:
# Внутри этого блока weather.requests И ЕСТЬ мок
mock_requests.get.return_value.json.return_value = {'temperature': 22}
result = get_temperature('Kyiv')
assert result == 22
# После блока weather.requests снова является реальным модулем requests
```
Строка `'weather.requests'` -- это адрес заменяемого имени: *"зайди в модуль `weather` и замени его атрибут `requests`"*.
---
## Построение полного мок-теста, строка за строкой
Полный тест с пояснениями:
```python
from unittest.mock import patch
import weather
@patch('weather.requests.get') # (1)
def test_get_temperature(mock_get): # (2)
mock_get.return_value.json.return_value = {'temperature': 22} # (3)
result = weather.get_temperature('Kyiv') # (4)
assert result == 22 # (5)
mock_get.assert_called_once_with( # (6)
'https://api.weather.example.com/temp/Kyiv'
)
```
**(1)** `@patch('weather.requests.get')` -- заменяет только `requests.get` внутри модуля `weather` (патчим конкретную функцию, а не весь модуль).
**(2)** `def test_get_temperature(mock_get)` -- pytest получает `MagicMock`, заменивший `requests.get`, как дополнительный аргумент.
**(3)** `mock_get.return_value.json.return_value = {'temperature': 22}` -- самая каверзная строка. Разберём её:
- `mock_get(...)` -- наш фиктивный `requests.get(...)` -- возвращает `mock_get.return_value` (фиктивный объект ответа)
- На этом объекте ответа вызывается `.json()` -- значит, `mock_get.return_value.json` -- это фиктивный метод `.json`
- `.return_value = {'temperature': 22}` делает так, что `.json()` возвращает наш словарь
Иными словами: `mock_get.return_value` = фиктивный ответ, `.json.return_value` = то, что возвращает `.json()`.
**(4)** `weather.get_temperature('Kyiv')` -- вызывает реальную функцию. Она вызовет `requests.get(...)`, который теперь является нашим моком, получит фиктивный ответ, вызовет на нём `.json()` и вернёт `22`.
**(5)** Проверяем, что результат соответствует запрограммированному.
**(6)** Проверяем, что код действительно вызвал API с правильным URL -- это верифицирует, что функция правильно построила URL, а не только то, что она вернула верное значение.
### Визуализация цепочки мока
```
weather.py вызывает: requests.get(url) -> resp -> resp.json() -> {'temperature': 22}
Мок-эквивалент: mock_get(url)
| возвращает
mock_get.return_value (фиктивный объект ответа)
| вызывается .json()
mock_get.return_value.json()
| возвращает
mock_get.return_value.json.return_value <- установите сюда свой словарь
```
Каждая `.` в цепочке добавляет один уровень `return_value`. Как только вы видите этот паттерн, цепочки становятся предсказуемыми.
---
## Что можно тестировать с моками, чего нельзя без них
```python
import requests
# Основной сценарий -- нормальный ответ:
mock_get.return_value.json.return_value = {'temperature': 22}
# Ошибка сервера -- API возвращает HTTP 500:
mock_get.return_value.raise_for_status.side_effect = requests.HTTPError('500 Server Error')
# Сбой сети -- нет соединения:
mock_get.side_effect = ConnectionError('network unreachable')
# Таймаут -- API слишком медленный:
mock_get.side_effect = requests.Timeout('read timeout')
```
Тестировать эти сценарии с реальным API практически невозможно. С моками -- по одной строке на каждый.
---
## Часть 1 -- unittest.mock (стандартная библиотека)
Несмотря на название, `unittest.mock` не привязан к фреймворку `unittest`. Это **универсальный модуль мокирования Python** (в стандартной библиотеке с Python 3.3), который одинаково используется pytest, unittest и любыми другими инструментами. Установка не требуется:
```python
from unittest.mock import MagicMock, patch
```
### MagicMock -- записывающий фиктивный объект
`MagicMock` автоматически создаёт любой атрибут или метод, к которому вы обращаетесь. Каждый вызов возвращает ещё один `MagicMock`, если не указано иное -- `AttributeError` не возникает никогда:
```python
from unittest.mock import MagicMock
mock = MagicMock()
mock.method.return_value = 42
result = mock.method('any', 'args') # -> 42
mock.method.assert_called_once_with('any', 'args') # проходит
print(mock.method.call_count) # 1
```
### patch -- замена имени в пространстве имён модуля
`patch` временно подменяет имя в пространстве имён модуля на время теста, затем автоматически восстанавливает оригинал.
**Форма декоратора** -- мок передаётся как дополнительный аргумент:
```python
from unittest.mock import patch
@patch('mymodule.requests.get')
def test_fetch(mock_get):
mock_get.return_value.json.return_value = {'price': 150}
result = mymodule.fetch_price('AAPL')
assert result == 150
```
**Форма контекстного менеджера** -- полезна, когда нужно патчить только часть теста:
```python
def test_fetch():
with patch('mymodule.requests.get') as mock_get:
mock_get.return_value.json.return_value = {'price': 150}
result = mymodule.fetch_price('AAPL')
assert result == 150
```
### Правило пути патча: где *используется*, а не где *определено*
Это самый распространённый источник путаницы. Патчите имя **так, как ваш модуль его видит**:
```python
# Если mymodule.py делает: import requests
@patch('mymodule.requests.get') # правильно
# Если mymodule.py делает: from requests import get
@patch('mymodule.get') # правильно -- 'get' теперь собственное имя в mymodule
@patch('requests.get') # неправильно -- mymodule.get уже держит отдельную ссылку
```
**Почему это важно:** когда Python выполняет `import requests` в вашем модуле, он создаёт имя `requests` в пространстве имён этого модуля, указывающее на объект модуля. Патчинг `mymodule.requests.get` заменяет атрибут `get` в этом пространстве имён. Если патчить `requests.get` напрямую, вы изменяете оригинальный модуль -- но если `mymodule` использовал `from requests import get`, у него уже есть собственная копия ссылки на функцию, и ваш патч её не затронет.
Мысленная модель: путь патча -- это почтовый адрес. `'weather.requests.get'` означает *"зайти в модуль weather (здание), найти объект requests (этаж), заменить get (дверь)"*. Нужно указать адрес, по которому функция *живёт* в вашем коде, а не откуда она была изначально доставлена.
### return_value и side_effect
```python
mock.return_value = 42 # возвращает 42 при каждом вызове
mock.side_effect = ConnectionError # выбрасывает этот класс исключения при вызове
mock.side_effect = [1, 2, 3] # возвращает элементы по порядку, по одному за вызов
mock.side_effect = lambda x: x * 2 # вызывает эту функцию с теми же аргументами
```
`side_effect` переопределяет `return_value` при установке.
### Распространённые ошибки начинающих
**Ошибка 1: патчинг неправильного места**
```python
# Код делает: import requests
@patch('requests.get') # неправильно -- патчит источник, а не место использования
@patch('mymodule.requests.get') # правильно
```
**Ошибка 2: забытое цепочечное return_value**
```python
# неправильно -- устанавливает то, что возвращает сам mock_get, а не то, что возвращает .json()
mock_get.return_value = {'temperature': 22}
# правильно -- resp.json() должен возвращать словарь, а не сам resp
mock_get.return_value.json.return_value = {'temperature': 22}
```
**Ошибка 3: утверждение до вызова**
```python
@patch('mymodule.func')
def test_something(mock_func):
mock_func.assert_called() # падает -- ничего ещё не вызывало его
result = mymodule.do_something()
mock_func.assert_called() # проходит -- сначала вызов, потом утверждение
```
**Ошибка 4: отсутствие утверждения вообще**
```python
@patch('mymodule.requests.get')
def test_fetch(mock_get):
mock_get.return_value.json.return_value = {'data': 1}
result = mymodule.fetch()
assert result == 1
# Отсутствует: проверка, что mock_get вызван с правильным URL
# Функция могла хардкодить неверный URL, и тест всё равно проходит
```
---
## Часть 2 -- pytest-mock (обёртка в стиле pytest)
`pytest-mock` -- тонкий плагин, оборачивающий `unittest.mock` в pytest-фикстуру под названием `mocker`. Установить один раз:
```bash
pip install pytest-mock
```
Фикстура `mocker` внедряется как любая другая pytest-фикстура -- **декоратор не нужен**:
```python
def test_fetch(mocker):
mock_get = mocker.patch('mymodule.requests.get')
mock_get.return_value.json.return_value = {'price': 150}
result = mymodule.fetch_price('AAPL')
assert result == 150
```
`mocker.patch` возвращает тот же объект `MagicMock`, поэтому все методы утверждений идентичны.
### Что предоставляет mocker
```python
mocker.patch('mymodule.func') # замена имени (возвращает MagicMock)
mocker.patch.object(instance, 'method') # замена метода на конкретном экземпляре
mocker.MagicMock() # создание автономного мока вручную
mocker.patch('mymodule.func', return_value=42) # сокращение: установка return_value сразу
```
Все моки, созданные через `mocker`, автоматически сбрасываются и останавливаются после завершения теста -- ручная очистка не нужна.
### Где mocker явно выигрывает: множественные патчи
При `@patch` стекирование декораторов переворачивает порядок аргументов -- известная ловушка:
```python
@patch('mymodule.requests.get')
@patch('mymodule.time.sleep')
@patch('mymodule.logger.warning')
def test_retry(mock_warn, mock_sleep, mock_get): # перевёрнуто! нижний декоратор -> первый аргумент
...
```
С `mocker` каждый патч -- именованная локальная переменная в естественном порядке:
```python
def test_retry(mocker):
mock_get = mocker.patch('mymodule.requests.get')
mock_sleep = mocker.patch('mymodule.time.sleep')
mock_warn = mocker.patch('mymodule.logger.warning')
# нет путаницы с порядком, каждый мок имеет осмысленное имя
```
### mocker в фикстурах -- естественное сочетание
Поскольку `mocker` -- это фикстура, её можно передавать напрямую в другие фикстуры:
```python
@pytest.fixture
def patched_client(mocker):
mocker.patch('mymodule.requests.get', return_value=mocker.MagicMock(
status_code=200,
json=lambda: {'results': []},
))
return mymodule.ApiClient()
```
С сырым `@patch` то же самое внутри фикстуры требует неудобного контекстного менеджера.
---
## Выбор между ними
| | `unittest.mock` напрямую | `pytest-mock` |
|---|---|---|
| **Установка** | встроен -- не нужна | `pip install pytest-mock` |
| **Стиль** | декоратор `@patch` или `with patch()` | параметр-фикстура `mocker` |
| **Множественные моки** | стекированные декораторы, перевёрнутый порядок | отдельные вызовы, именованные переменные |
| **Очистка** | через декоратор / контекстный менеджер | автоматически после каждого теста |
| **Использование в фикстурах** | неудобно (контекстный менеджер в фикстуре) | естественно (передать `mocker` как аргумент) |
| **Базовые объекты** | `MagicMock`, `call` и т.д. | идентичны -- просто обёрнуты |
Оба распространены в реальных pytest-проектах. Используйте `unittest.mock` напрямую, когда нужны нулевые дополнительные зависимости или пишете библиотечный код. Предпочитайте `pytest-mock` в выделенном pytest-проекте -- он хорошо вписывается в модель фикстур и устраняет ловушку с порядком аргументов декораторов при патчинге нескольких вещей.
## Тестируемый код
```python
# price.py
import requests
import time
BASE_URL = 'https://api.market.example.com'
def fetch_price(ticker):
resp = requests.get(f'{BASE_URL}/price/{ticker}')
resp.raise_for_status()
return resp.json()['price']
def fetch_price_with_retry(ticker, retries=3):
for attempt in range(retries):
try:
return fetch_price(ticker)
except ConnectionError:
if attempt < retries - 1:
time.sleep(1)
raise ConnectionError('all retries failed')
```
## 1. Базовый мок -- один тест, два стиля
```python
# Использование unittest.mock (декоратор @patch)
from unittest.mock import patch
import pytest, price
@patch('price.requests.get')
def test_fetch_price_stdlib(mock_get):
mock_get.return_value.json.return_value = {'price': 150.0}
mock_get.return_value.raise_for_status.return_value = None
result = price.fetch_price('AAPL')
assert result == 150.0
mock_get.assert_called_once_with(f'{price.BASE_URL}/price/AAPL')
# Использование pytest-mock (фикстура mocker)
def test_fetch_price_mocker(mocker):
mock_get = mocker.patch('price.requests.get')
mock_get.return_value.json.return_value = {'price': 150.0}
mock_get.return_value.raise_for_status.return_value = None
result = price.fetch_price('AAPL')
assert result == 150.0
mock_get.assert_called_once_with(f'{price.BASE_URL}/price/AAPL')
```
Логика идентична. Единственные отличия: декоратор против параметра-фикстуры и способ получения мока. Объект `MagicMock` и все его методы утверждений одинаковы.
## 2. side_effect -- оба стиля
```python
# stdlib
@patch('price.requests.get')
def test_fetch_raises_stdlib(mock_get):
mock_get.side_effect = ConnectionError('network down')
with pytest.raises(ConnectionError):
price.fetch_price('AAPL')
# pytest-mock
def test_fetch_raises_mocker(mocker):
mock_get = mocker.patch('price.requests.get')
mock_get.side_effect = ConnectionError('network down')
with pytest.raises(ConnectionError):
price.fetch_price('AAPL')
```
## 3. Множественные патчи -- где mocker выигрывает
Тестирование логики повторных попыток требует мокирования и `requests.get`, и `time.sleep`.
**stdlib -- стекированные декораторы, перевёрнутый порядок аргументов:**
```python
from unittest.mock import patch, MagicMock
import price
@patch('price.requests.get') # <- применяется вторым (внешний)
@patch('price.time.sleep') # <- применяется первым (внутренний)
def test_retry_stdlib(mock_sleep, mock_get): # внутренний декоратор -> первый аргумент
success = MagicMock()
success.json.return_value = {'price': 99.0}
success.raise_for_status.return_value = None
mock_get.side_effect = [ConnectionError(), ConnectionError(), success]
result = price.fetch_price_with_retry('AAPL')
assert result == 99.0
assert mock_sleep.call_count == 2
```
Перевёрнутый порядок аргументов (`mock_sleep` перед `mock_get`, хотя `@patch('...get')` идёт первым) -- широко известный источник багов. Легко перепутать молча -- оба мока являются объектами `MagicMock` без типа, который поймал бы подмену.
**pytest-mock -- именованные переменные, естественный порядок:**
```python
def test_retry_mocker(mocker):
mock_get = mocker.patch('price.requests.get')
mock_sleep = mocker.patch('price.time.sleep')
success = mocker.MagicMock()
success.json.return_value = {'price': 99.0}
success.raise_for_status.return_value = None
mock_get.side_effect = [ConnectionError(), ConnectionError(), success]
result = price.fetch_price_with_retry('AAPL')
assert result == 99.0
assert mock_sleep.call_count == 2
```
Каждый мок имеет осмысленное имя. Добавление третьего патча -- просто ещё одна строка, без переупорядочивания декораторов и перемешивания аргументов.
## 4. patch.object -- оба стиля
```python
class ApiClient:
def get(self, url):
... # реальный сетевой вызов
# stdlib -- нужен контекстный менеджер для патча на уровне экземпляра:
def test_client_stdlib():
client = ApiClient()
with patch.object(client, 'get', return_value={'data': 'ok'}) as mock_get:
result = client.get('/resource')
assert result == {'data': 'ok'}
mock_get.assert_called_once_with('/resource')
# pytest-mock -- нет контекстного менеджера, очистка автоматическая:
def test_client_mocker(mocker):
client = ApiClient()
mock_get = mocker.patch.object(client, 'get', return_value={'data': 'ok'})
result = client.get('/resource')
assert result == {'data': 'ok'}
mock_get.assert_called_once_with('/resource')
```
## 5. mocker внутри фикстуры
```python
# conftest.py
import pytest
@pytest.fixture
def mock_price_service(mocker):
mock = mocker.patch('price.requests.get')
mock.return_value.raise_for_status.return_value = None
mock.return_value.json.return_value = {'price': 42.0}
return mock
# тестовый файл -- мок уже активен при запуске теста
def test_uses_mock_fixture(mock_price_service):
import price
result = price.fetch_price('AAPL')
assert result == 42.0
mock_price_service.assert_called_once()
```
Передача `mocker` в фикстуру естественна -- это просто ещё одна зависимость-фикстура. То же самое с `@patch` требует контекстного менеджера внутри фикстуры, что менее читаемо.