## Проблема, яку вирішує мокінг
Починаємо з простої функції, яка отримує поточну температуру для міста:
```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
```
### Правило шляху patch: де *використовується*, а не де *визначено*
Це найпоширеніше джерело плутанини. Патчте ім'я **так, як ваш модуль його бачить**:
```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`, він вже має власну копію посилання на функцію, і ваш патч її ніколи не торкнеться.
Ментальна модель: шлях patch -- це поштова адреса. `'weather.requests.get'` означає *"зайди в модуль weather (будівлю), знайди об'єкт requests (поверх), замін get (двері)"*. Ви повинні вказати адресу того, де функція *живе* у вашому коді, а не звідки вона була доставлена.
### return_value vs 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 inline
```
Всі моки, створені через `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')
```
Логіка ідентична. Єдині відмінності: декоратор vs параметр-фікстура та спосіб отримання моку. Об'єкт `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` потребує контекстного менеджера всередині фікстури, що менш читабельно.