Python · Тестування з pytest · Середній

Мокування з unittest.mock

5 завдань

Ізолюйте код, що тестується, підмінюючи залежності на контрольовані фейки.

Мокінг: unittest.mock і pytest-mock

#
## Проблема, яку вирішує мокінг Починаємо з простої функції, яка отримує поточну температуру для міста: ```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-проекті -- він добре вписується у модель фікстур і усуває пастку зворотного порядку аргументів декоратора при патчингу кількох речей.

unittest.mock і pytest-mock: порівняння

#
## Код, що тестується ```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` потребує контекстного менеджера всередині фікстури, що менш читабельно.

Довідник: мокінг

#
**Встановлення pytest-mock:** ```bash pip install pytest-mock ``` --- **Шпаргалка з порівнянням:** | Завдання | `unittest.mock` | `pytest-mock` | |------|-----------------|---------------| | Патчити ім'я | декоратор `@patch('mod.func')` | `mocker.patch('mod.func')` | | Патчити лише частину тесту | `with patch('mod.func') as m:` | той самий `mocker.patch()` -- автоочищення | | Патчити метод екземпляра | `patch.object(obj, 'method')` | `mocker.patch.object(obj, 'method')` | | Створити окремий мок | `MagicMock()` | `mocker.MagicMock()` | | Кілька патчів | стековані декоратори (зворотні аргументи) | окремі виклики, іменовані змінні | | Використати у фікстурі | контекстний менеджер у тілі фікстури | передати `mocker` як аргумент-фікстуру | --- **return_value / side_effect (ідентично в обох):** ```python mock.return_value = 42 mock.side_effect = ValueError # підіймає клас винятку mock.side_effect = [1, 2, 3] # послідовні повернення mock.side_effect = lambda x: x * 2 # колбек -- викликається з тими самими аргументами ``` **Твердження (ідентично в обох):** ```python mock.assert_called() mock.assert_called_once() mock.assert_called_with(*args, **kw) # останній виклик mock.assert_called_once_with(*args, **kw) # рівно один виклик mock.assert_not_called() mock.call_count # ціле число mock.call_args_list # список усіх викликів ``` **Правило шляху patch (однакове в обох):** Завжди патчіть `'your_module.dependency'`, а не `'original_module.dependency'`. Для `from x import y` у вашому модулі: патчіть `'your_module.y'`. **Кілька патчів з mocker:** ```python def test_something(mocker): mock_a = mocker.patch('mod.func_a') # кожен має чітку назву mock_b = mocker.patch('mod.func_b') # порядок не важливий mock_c = mocker.patch('mod.func_c') ``` **Кілька патчів з @patch (зворотні аргументи):** ```python @patch('mod.func_a') # найзовнішній → останній аргумент @patch('mod.func_b') @patch('mod.func_c') # найвнутрішній → перший аргумент def test_something(mc, mb, ma): # зворотно! ... ```
01

Мок для значення, що повертається (unittest.mock)

#

У вас є `get_discounted_price(product_id)` у `store.py`, яка викликає `get_price(product_id)` і повертає `round(price * 0.9, 2)`. Напишіть тест з `@patch`, щоб змокати `store.get_price` з поверненням `100.0` і перевірте, що `get_discounted_price` повертає `90.0`.

# store.py
def get_price(product_id):
    raise NotImplementedError('would call external API')

def get_discounted_price(product_id):
    price = get_price(product_id)
    return round(price * 0.9, 2)


# test_store.py
from unittest.mock import patch
import store


@patch('store.get_price')
def test_discounted_price(mock_get_price):
    # встановіть mock_get_price.return_value = 100.0
    # викличте store.get_discounted_price('laptop')
    # перевірте, що result == 90.0
    pass
Рішення
# store.py
def get_price(product_id):
    raise NotImplementedError('would call external API')

def get_discounted_price(product_id):
    price = get_price(product_id)
    return round(price * 0.9, 2)


# test_store.py
from unittest.mock import patch
import store


@patch('store.get_price')
def test_discounted_price(mock_get_price):
    mock_get_price.return_value = 100.0

    result = store.get_discounted_price('laptop')

    assert result == 90.0
02

Мок для requests.get (unittest.mock)

#

У вас є `fetch_user(user_id)` у `fetcher.py`, яка викликає `requests.get` для отримання `https://api.example.com/users/{user_id}` і повертає `resp.json()`. Напишіть тест з `@patch`, щоб не було реального HTTP-виклику. Перевірте значення, що повертається, і що `requests.get` було викликано з правильним URL.

# fetcher.py
import requests

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


# test_fetcher.py
from unittest.mock import patch
import fetcher


@patch('fetcher.requests.get')
def test_fetch_user(mock_get):
    # налаштуйте: mock_get.return_value.json.return_value = {...}
    # викличте fetcher.fetch_user(1)
    # перевірте результат і URL
    pass
Рішення
# fetcher.py
import requests

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


# test_fetcher.py
from unittest.mock import patch
import fetcher


@patch('fetcher.requests.get')
def test_fetch_user(mock_get):
    mock_get.return_value.json.return_value = {'id': 1, 'name': 'Alice'}

    result = fetcher.fetch_user(1)

    assert result == {'id': 1, 'name': 'Alice'}
    mock_get.assert_called_once_with('https://api.example.com/users/1')
03

Перевірка аргументів виклику (pytest-mock)

#

Перепишіть сценарій `notify_user` з використанням pytest-mock. У вас є `notify_user(user, message)` у `notifier.py`, яка викликає `send_sms(phone, message)`. Напишіть тест з фікстурою `mocker` (не `@patch`), який патчить `notifier.send_sms` і перевіряє, що він був викликаний рівно один раз з правильним номером телефону і повідомленням. Спочатку встановіть: `pip install pytest-mock`.

# notifier.py
def send_sms(phone, message):
    raise NotImplementedError('calls external SMS gateway')

def notify_user(user, message):
    send_sms(user['phone'], message)


# test_notifier.py
import notifier


def test_notify_calls_sms_with_correct_args(mocker):
    # mock_send = mocker.patch('notifier.send_sms')
    # викличте notifier.notify_user зі словником користувача і повідомленням
    # mock_send.assert_called_once_with(...)
    pass
Рішення
# notifier.py
def send_sms(phone, message):
    raise NotImplementedError('calls external SMS gateway')

def notify_user(user, message):
    send_sms(user['phone'], message)


# test_notifier.py
import notifier


def test_notify_calls_sms_with_correct_args(mocker):
    mock_send = mocker.patch('notifier.send_sms')
    user = {'name': 'Alice', 'phone': '+380501234567'}

    notifier.notify_user(user, 'Your order is ready')

    mock_send.assert_called_once_with('+380501234567', 'Your order is ready')
04

side_effect для підняття винятку (pytest-mock)

#

У вас є `safe_fetch(url)` у `client.py`, яка загортає `requests.get` у try/except: при успіху повертає `resp.json()`; при `ConnectionError` повертає `None`. Напишіть тест з фікстурою `mocker`, що встановлює `side_effect = ConnectionError(...)` на пропатченому `requests.get` і перевіряє, що `safe_fetch` повертає `None`.

# client.py
import requests

def safe_fetch(url):
    try:
        resp = requests.get(url, timeout=5)
        return resp.json()
    except ConnectionError:
        return None


# test_client.py
import client


def test_safe_fetch_returns_none_on_error(mocker):
    # mock_get = mocker.patch('client.requests.get')
    # встановіть mock_get.side_effect = ConnectionError(...)
    # викличте client.safe_fetch(...)
    # перевірте, що result is None
    pass
Рішення
# client.py
import requests

def safe_fetch(url):
    try:
        resp = requests.get(url, timeout=5)
        return resp.json()
    except ConnectionError:
        return None


# test_client.py
import client


def test_safe_fetch_returns_none_on_error(mocker):
    mock_get = mocker.patch('client.requests.get')
    mock_get.side_effect = ConnectionError('network unreachable')

    result = client.safe_fetch('https://api.example.com/data')

    assert result is None
05

Мок для методу екземпляра через mocker.patch.object

#

У вас є `EmailService` з методом `send(to, subject, body)` і `UserRegistration`, яка викликає його у `register(user)`. Напишіть тест з `mocker.patch.object` для мокінгу методу `send` на конкретному екземплярі `EmailService`. Перевірте, що `register` повертає `True` і `send` було викликано з правильними ключовими аргументами.

# registration.py

class EmailService:
    def send(self, to, subject, body):
        raise NotImplementedError('sends real email in production')

class UserRegistration:
    def __init__(self, email_service):
        self.email_service = email_service

    def register(self, user):
        self.email_service.send(
            to=user['email'],
            subject='Welcome!',
            body=f"Hello {user['name']}, your account is ready.",
        )
        return True


# test_registration.py
from registration import EmailService, UserRegistration


def test_registration_sends_welcome_email(mocker):
    email_service = EmailService()
    registration = UserRegistration(email_service)
    user = {'name': 'Bob', 'email': '[email protected]'}

    # mock_send = mocker.patch.object(email_service, 'send')
    # викличте registration.register(user)
    # перевірте, що result is True
    # перевірте mock_send.assert_called_once_with(to=..., subject=..., body=...)
    pass
Рішення
# registration.py

class EmailService:
    def send(self, to, subject, body):
        raise NotImplementedError('sends real email in production')

class UserRegistration:
    def __init__(self, email_service):
        self.email_service = email_service

    def register(self, user):
        self.email_service.send(
            to=user['email'],
            subject='Welcome!',
            body=f"Hello {user['name']}, your account is ready.",
        )
        return True


# test_registration.py
from registration import EmailService, UserRegistration


def test_registration_sends_welcome_email(mocker):
    email_service = EmailService()
    registration = UserRegistration(email_service)
    user = {'name': 'Bob', 'email': '[email protected]'}

    mock_send = mocker.patch.object(email_service, 'send')

    result = registration.register(user)

    assert result is True
    mock_send.assert_called_once_with(
        to='[email protected]',
        subject='Welcome!',
        body='Hello Bob, your account is ready.',
    )