## Піраміда тестів
Не всі тести слугують одній меті. Здоровий тест-сьют використовує три окремі шари:
```
┌─────────────────┐
│ End-to-End │ мало, повільні, тестують всю систему
├─────────────────┤
│ Інтеграційні │ більше, тестують реальні компоненти разом
├─────────────────┤
│ Юніт │ багато, швидкі, тестують одне в ізоляції
└─────────────────┘
```
Більшість проектів має мати багато юніт-тестів, помірну кількість інтеграційних і невелику кількість наскрізних тестів. Така форма є класичною, оскільки кожен шар має різні витрати і переваги.
## Юніт-тести: швидкі та ізольовані
Юніт-тест виконує єдину функцію або клас у повній ізоляції. Кожна зовнішня залежність -- бази даних, HTTP API, файлова система -- замінюється моком або заглушкою.
```python
# Юніт-тест: без реального HTTP-запиту
def test_get_product_count_success():
with patch('myapp.requests.get') as mock_get:
mock_get.return_value.json.return_value = {'count': 42}
mock_get.return_value.status_code = 200
result = get_product_count()
assert result == 42
```
**Переваги:** мілісекунди на тест, без зовнішніх залежностей, точне визначення місця збою.
**Обмеження:** моки можуть відходити від реальності. Якщо API повертає `{'total': 42}` замість `{'count': 42}`, юніт-тест все одно проходить -- мок був неправильним.
## Інтеграційні тести: реальні компоненти, реальні контракти
Інтеграційний тест виконує кілька реальних компонентів разом. Для тестування API це означає виконання реальних HTTP-запитів до реального сервера, розбір реального JSON, перевірку реальних кодів статусу.
```python
# Інтеграційний тест: реальний HTTP-запит до справжнього сервера
def test_get_product_count():
resp = requests.get('https://apilearn.tukas.dev/api/products/')
assert resp.status_code == 200
data = resp.json()
assert 'results' in data
result = len(data['results'])
assert result >= 0
```
**Переваги:** виявляє реальні інтеграційні баги -- невідповідності схеми, збої автентифікації, несподівані редиректи, справжні форми відповідей. Тест використовує той самий HTTP-шлях, що і ваш продакшн-код.
**Витрати:** повільніші (мережевий round-trip), можуть бути нестабільними (проблеми з мережею або сервером), потребують реального сервера, можуть залишати дані.
## Наскрізні тести: вся система
Наскрізний тест керує всією системою через її реальний інтерфейс -- зазвичай браузер для веб-застосунків або повну поверхню CLI/API. Він тестує, що всі шари (фронтенд, бекенд, база даних) працюють разом.
Наскрізні тести найдорожчі: повільні, крихкі, складні в налагодженні. Використовуйте їх рідко -- для найбільш критичних користувацьких сценаріїв.
## Що робить тест інтеграційним?
Тест є інтеграційним, якщо він:
- Виконує реальні мережеві виклики (HTTP, gRPC, WebSocket)
- Читає або записує у реальну базу даних
- Читає або записує реальні файли на диску
- Використовує реальні зовнішні сервіси (провайдери автентифікації, платіжні шлюзи, черги)
Якщо тест замість цього використовує моки, він є юніт-тестом незалежно від того, скільки функцій він викликає всередині.
## Витрати інтеграційних тестів
**Повільні:** мережевий round-trip займає 50-500 мс. Юніт-тест займає 0.1 мс. Сто інтеграційних тестів займуть 5-50 секунд; сто юніт-тестів -- 0.01 секунди.
**Нестабільні:** мережеві збої, таймаути, ліміти запитів і перезапуски сервера -- все це спричиняє провали інтеграційних тестів з причин, не пов'язаних з вашим кодом. Нестабільний тест-сьют підриває довіру до CI.
**Забруднення стану:** інтеграційні тести можуть створювати дані, що впливають на наступні тести. Тест, який створює користувача, але не видаляє його, може спричинити збій іншого тесту, що перевіряє "немає користувачів з цим username".
**Залежності:** інтеграційні тести потребують запущеного реального сервера. У CI це означає розгортання тестового середовища, очікування його готовності, а потім запуск тестів.
## Стратегії ізоляції тестів
**Використовуйте унікальні ідентифікатори:** при створенні користувачів або ресурсів генеруйте унікальні значення (мітки часу, UUID), щоб тести не конфліктували між собою або з наявними даними:
```python
import time
def test_register_and_login():
username = f'testuser_{int(time.time())}'
...
```
**Прибирайте за собою:** якщо тест створює ресурс, видаліть його в кінці. Використовуйте для цього teardown фікстури:
```python
@pytest.fixture
def temp_user():
username = f'tmp_{int(time.time())}'
# setup: створити користувача
resp = requests.post(BASE_URL + '/api/users/register/', json={...})
yield {'username': username, 'token': get_token(username)}
# teardown: видалити користувача
requests.delete(BASE_URL + f'/api/users/{username}/', headers=auth_headers)
```
**Використовуйте виділений тестовий акаунт:** для тестів, що читають дані (а не пишуть), автентифікуйтеся з відомим тестовим акаунтом, а не створюйте нових користувачів. Акаунт `testuser` / `TestUser2024!` на `apilearn.tukas.dev` підходить для цього.
**Session-scoped автентифікація:** якщо багато тестів потребують автентифікації, увійдіть один раз за допомогою session-scoped фікстури і спільно використовуйте токен. Це скорочує накладні витрати на вхід з N round-trips до 1.
## Запуск інтеграційних тестів окремо від юніт-тестів
Позначте інтеграційні тести і запускайте їх окремо:
```ini
# pytest.ini
markers =
integration: marks tests that make real network calls
```
```python
@pytest.mark.integration
def test_products_api():
...
```
```bash
pytest -m integration # лише інтеграційні тести
pytest -m "not integration" # лише юніт-тести (швидкий CI)
```
Це дозволяє запускати швидкий юніт-набір при кожному коміті, а повільніший інтеграційний -- щоночі або за потребою.
## Порівняння двох підходів
Та сама функціональність, реалізована як юніт-тест і як інтеграційний тест:
```python
# Функція, що тестується
import requests
def get_product_count(base_url='https://apilearn.tukas.dev'):
resp = requests.get(f'{base_url}/api/products/')
resp.raise_for_status()
return resp.json()['count']
# Юніт-тест: швидкий, ізольований, але мок може відходити від реальності
def test_get_product_count_unit():
from unittest.mock import patch
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {'count': 5}
mock_get.return_value.raise_for_status.return_value = None
assert get_product_count() == 5
# Інтеграційний тест: повільніший, але тестує реальний контракт API
def test_get_product_count_integration():
import requests
resp = requests.get('https://apilearn.tukas.dev/api/products/')
assert resp.status_code == 200
data = resp.json()
assert 'count' in data or 'results' in data # що б API не повертав
```
Зверніть увагу, що юніт-тест припускає, що API повертає `{'count': 5}`. Якщо реальний API повертає `{'results': [...], 'count': 5}`, юніт-тест перевіряє мок, що відповідає реальності -- добре. Але якщо поле насправді називається `'total'`, юніт-тест проходить, тоді як інтеграційний виявляє невідповідність.
Інтеграційні тести на практиці: потоки автентифікації та спільні сесії
## Налаштування проекту
```bash
pip install requests pytest
```
```ini
# pytest.ini
[pytest]
markers =
integration: marks tests that make real network calls
```
Всі приклади використовують `https://apilearn.tukas.dev`.
## Приклад 1: потік автентифікації -- вхід і доступ до захищеного ендпоінту
```python
# test_auth_flow.py
import requests
BASE_URL = 'https://apilearn.tukas.dev'
def test_login_and_profile():
# Крок 1: автентифікація
auth_resp = requests.post(f'{BASE_URL}/api/auth/token/', json={
'username': 'testuser',
'password': 'TestUser2024!',
})
assert auth_resp.status_code == 200
token = auth_resp.json()['token']
assert token # непорожній рядок
# Крок 2: використати токен для доступу до захищеного ендпоінту
headers = {'Authorization': f'Token {token}'}
profile_resp = requests.get(f'{BASE_URL}/api/users/profile/', headers=headers)
assert profile_resp.status_code == 200
profile = profile_resp.json()
assert profile['username'] == 'testuser'
```
Це справжній інтеграційний тест: він виконує два реальні HTTP-запити, розбирає реальний JSON і перевіряє реальну відповідь. Якщо сервіс автентифікації змінить назву поля токена, або ендпоінт профілю змінить `username` на `user`, цей тест одразу це виявить.
## Приклад 2: session-scoped фікстура автентифікації
Вхід при кожному тесті -- це повільно і марнотратно. Session-scoped фікстура виконує вхід один раз:
```python
# conftest.py
import pytest
import requests
BASE_URL = 'https://apilearn.tukas.dev'
@pytest.fixture(scope='session')
def base_url():
return BASE_URL
@pytest.fixture(scope='session')
def auth_headers(base_url):
resp = requests.post(f'{base_url}/api/auth/token/', json={
'username': 'testuser',
'password': 'TestUser2024!',
})
resp.raise_for_status()
token = resp.json()['token']
return {'Authorization': f'Token {token}'}
```
```python
# test_protected.py
def test_profile_username(base_url, auth_headers):
resp = requests.get(f'{base_url}/api/users/profile/', headers=auth_headers)
assert resp.status_code == 200
assert resp.json()['username'] == 'testuser'
def test_profile_has_email(base_url, auth_headers):
resp = requests.get(f'{base_url}/api/users/profile/', headers=auth_headers)
assert resp.status_code == 200
assert '@' in resp.json().get('email', '')
def test_products_authenticated(base_url, auth_headers):
resp = requests.get(f'{base_url}/api/products/', headers=auth_headers)
assert resp.status_code == 200
```
Один HTTP-вхід на весь тестовий сеанс. Всі три тести спільно використовують той самий словник `auth_headers`. Session-scope тут доречний, оскільки токен не змінюється між тестами.
## Приклад 3: пагінація як інтеграційний аспект
Контракти пагінації (розмір сторінки, кількість результатів, URL `next`) -- саме те, що юніт-тести з моками не можуть перевірити:
```python
# test_pagination.py
def test_default_page_returns_results(base_url):
resp = requests.get(f'{base_url}/api/products/')
assert resp.status_code == 200
data = resp.json()
assert 'results' in data
assert len(data['results']) > 0
def test_page_size_respected(base_url):
resp = requests.get(f'{base_url}/api/products/?page_size=2')
assert resp.status_code == 200
results = resp.json()['results']
assert len(results) <= 2
def test_page_two_exists_when_enough_products(base_url):
first = requests.get(f'{base_url}/api/products/?page=1&page_size=2')
assert first.status_code == 200
data = first.json()
if data.get('next'):
second = requests.get(f'{base_url}/api/products/?page=2&page_size=2')
assert second.status_code == 200
assert isinstance(second.json()['results'], list)
```
## Приклад 4: юніт-тест vs інтеграційний тест -- пліч-о-пліч
Та сама функція `get_product_names`, протестована двома способами:
```python
# app.py
import requests
def get_product_names(base_url='https://apilearn.tukas.dev'):
resp = requests.get(f'{base_url}/api/products/')
resp.raise_for_status()
return [p['name'] for p in resp.json()['results']]
```
```python
# Юніт-тест -- швидкий, але мок може розходитися з реальним API
from unittest.mock import patch
def test_get_product_names_unit():
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status.return_value = None
mock_get.return_value.json.return_value = {
'results': [{'name': 'Widget'}, {'name': 'Gadget'}]
}
names = get_product_names()
assert names == ['Widget', 'Gadget']
# Цей тест проходить, навіть якщо реальний API повертає {'name': None} для деяких елементів.
# Інтеграційний тест -- повільніший, але перевіряє реальний контракт
def test_get_product_names_integration():
names = get_product_names()
# Не знаємо точних значень, тому перевіряємо структуру:
assert isinstance(names, list)
assert len(names) > 0
assert all(isinstance(n, str) for n in names)
assert all(len(n) > 0 for n in names)
```
Інтеграційний тест не може перевіряти точні значення (назви продуктів змінюються), але верифікує контракт: `get_product_names` повертає непорожній список непорожніх рядків. Він провалиться, якщо API поверне `null` для `name`, або якщо структура зміниться -- саме ті баги, що мають значення у продакшні.
## Приклад 5: потік реєстрації з унікальними даними
Коли тести створюють дані, використовуйте унікальні ідентифікатори для уникнення колізій:
```python
# test_registration.py
import time
import requests
BASE_URL = 'https://apilearn.tukas.dev'
def test_register_and_login():
# Генеруємо унікальний username для цього запуску тестів
username = f'pytest_{int(time.time())}'
# Крок 1: реєстрація
reg_resp = requests.post(f'{BASE_URL}/api/users/register/', json={
'username': username,
'email': f'{username}@example.com',
'password': 'Secure123!',
'first_name': 'Test',
'last_name': 'User',
})
assert reg_resp.status_code in (200, 201)
# Крок 2: вхід з новими обліковими даними
auth_resp = requests.post(f'{BASE_URL}/api/auth/token/', json={
'username': username,
'password': 'Secure123!',
})
assert auth_resp.status_code == 200
token = auth_resp.json()['token']
# Крок 3: перевірка профілю
profile_resp = requests.get(f'{BASE_URL}/api/users/profile/',
headers={'Authorization': f'Token {token}'})
assert profile_resp.status_code == 200
assert profile_resp.json()['username'] == username
```
`f'pytest_{int(time.time())}'` генерує username на кшталт `pytest_1735689600` -- унікальний з точністю до секунди. Якщо тести виконуються паралельно, додайте також випадковий суфікс.
## Класифікація тестів
| Тип | Реальна мережа? | Реальна БД? | Швидкість | Для чого |
|---|---|---|---|---|
| Юніт | Ні (замоковано) | Ні | Швидко | Логіка, алгоритми, трансформації |
| Інтеграційний | Так | Так | Повільно | Контракти API, потоки автентифікації, пайплайни даних |
| Наскрізний | Так | Так | Найповільніше | Повні користувацькі сценарії |
## Шаблон session-scoped фікстури автентифікації
```python
# conftest.py
import pytest
import requests
BASE_URL = 'https://apilearn.tukas.dev'
@pytest.fixture(scope='session')
def base_url():
return BASE_URL
@pytest.fixture(scope='session')
def auth_headers(base_url):
resp = requests.post(f'{base_url}/api/auth/token/', json={
'username': 'testuser', 'password': 'TestUser2024!',
})
resp.raise_for_status()
return {'Authorization': f'Token {resp.json()["token"]}'}
```
## Поширені перевірки API
```python
# Статус
assert resp.status_code == 200
assert resp.status_code in (200, 201)
# Структура JSON
data = resp.json()
assert 'results' in data
assert isinstance(data['results'], list)
# Пагінація
assert len(data['results']) <= page_size
# Наявність полів
assert all('slug' in p for p in data['results'])
# Потрібна автентифікація (має повернути редирект або 401 без токена)
resp = requests.get(url) # без заголовків
assert resp.status_code == 401
```
## Унікальні тестові дані (уникнення колізій)
```python
import time, uuid
username = f'test_{int(time.time())}' # унікальний з точністю до секунди
username = f'test_{uuid.uuid4().hex[:8]}' # унікальний при кожному виклику
```
## Позначення та відбір інтеграційних тестів
```ini
# pytest.ini
markers =
integration: marks tests that require network access
```
```python
@pytest.mark.integration
def test_something():
...
```
```bash
pytest -m integration # лише інтеграційні
pytest -m "not integration" # лише юніт-тести (швидкі)
```
## Чекліст ізоляції тестів
- [ ] Використовувати унікальні username/ID при створенні ресурсів
- [ ] Видаляти створені ресурси у teardown фікстури
- [ ] Використовувати session-scope для автентифікації (вхід один раз)
- [ ] Перевіряти структуру та обмеження, а не точні значення, що можуть змінюватися
- [ ] Обробляти і 200, і 201, де обидва є допустимими
Напишіть інтеграційний тест `test_register_and_login`, що:
1. Генерує унікальний username через `f'pytest_{int(time.time())}'`
2. Реєструє користувача через `POST /api/users/register/` з полями: `username`, `email`, `password`, `first_name`, `last_name`
3. Перевіряє, що відповідь реєстрації є 200 або 201
4. Виконує вхід через `POST /api/auth/token/` з новими обліковими даними
5. Виконує GET `/api/users/profile/` з поверненим токеном
6. Перевіряє, що `username` профілю збігається з зареєстрованим
Використовуйте `BASE_URL = 'https://apilearn.tukas.dev'`.
Напишіть session-scoped фікстуру `base_url`, що повертає `'https://apilearn.tukas.dev'`, потім напишіть три тести:
1. `test_products_returns_results` -- GET `/api/products/`, перевірте статус 200, наявність ключа `'results'`, що результат є списком з принаймні 1 елементом
2. `test_page_size_two` -- GET `/api/products/?page_size=2`, перевірте, що список results має **не більше** 2 елементів
3. `test_second_page` -- GET `/api/products/?page=2&page_size=2`, перевірте статус 200 (сторінка 2 може бути порожньою, але не має повертати 404)
Напишіть `conftest.py` з двома session-scoped фікстурами:
- `base_url` -- повертає `'https://apilearn.tukas.dev'`
- `auth_headers` -- виконує вхід як `testuser` / `TestUser2024!` і повертає `{'Authorization': 'Token ...'}`
Напишіть три тести, кожен з яких використовує `auth_headers`:
1. `test_profile_username` -- перевірте, що відповідь профілю має `username == 'testuser'`
2. `test_products_with_auth` -- перевірте, що GET `/api/products/` повертає 200 (автентифікований)
3. `test_echo_with_auth` -- перевірте, що GET `/api/echo/` повертає 200
Реалізуйте `get_product_count(base_url)` -- функцію, що виконує GET `{base_url}/api/products/` і повертає ціле значення поля `count` у відповіді JSON.
Напишіть два тести:
1. `test_get_product_count_unit` -- використайте `unittest.mock.patch` для мокування `requests.get`, змусьте його повертати фейкову JSON-відповідь `{'count': 7, 'results': []}` і перевірте, що `get_product_count(...)` повертає `7`. Тест **не повинен** виконувати реальний мережевий виклик.
2. `test_get_product_count_integration` -- викличте `get_product_count('https://apilearn.tukas.dev')` реально, перевірте, що результат є цілим числом >= 0.
Додайте коментар у кожному тесті, що пояснює, що він може і не може виявити.
# test_count_comparison.py
import requests
from unittest.mock import patch
def get_product_count(base_url):
resp = requests.get(f'{base_url}/api/products/')
resp.raise_for_status()
return resp.json()['count']
def test_get_product_count_unit():
# Цей тест: швидкий, без мережі.
# Не може виявити: поле перейменоване в реальному API, неправильний URL, проблеми з автентифікацією.
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status.return_value = None
mock_get.return_value.json.return_value = ...
result = get_product_count('http://fake')
assert result == ...
def test_get_product_count_integration():
# Цей тест: повільніший, реальна мережа.
# Не може виявити: баги логіки всередині get_product_count (покривається юніт-тестом).
result = get_product_count('https://apilearn.tukas.dev')
assert ...
Рішення
# test_count_comparison.py
import requests
from unittest.mock import patch
def get_product_count(base_url):
resp = requests.get(f'{base_url}/api/products/')
resp.raise_for_status()
return resp.json()['count']
def test_get_product_count_unit():
# Цей тест: швидкий, без мережі.
# Не може виявити: поле перейменоване в реальному API, неправильний URL, проблеми з автентифікацією.
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status.return_value = None
mock_get.return_value.json.return_value = {'count': 7, 'results': []}
result = get_product_count('http://fake')
assert result == 7
def test_get_product_count_integration():
# Цей тест: повільніший, реальна мережа.
# Не може виявити: баги логіки всередині get_product_count (покривається юніт-тестом).
result = get_product_count('https://apilearn.tukas.dev')
assert isinstance(result, int)
assert result >= 0
05
Session-scoped фікстура, що використовується в трьох незалежних тестах
Побудуйте session-scoped фікстуру `product_list`, що:
- Виконує GET `https://apilearn.tukas.dev/api/products/`
- Перевіряє статус 200
- Повертає розібраний список `results`
Напишіть три незалежні тести, кожен з яких отримує `product_list` як параметр фікстури:
1. `test_products_not_empty` -- перевірте, що список має принаймні 1 елемент
2. `test_products_have_slug` -- перевірте, що кожен продукт має непорожнє поле `slug`
3. `test_products_have_name` -- перевірте, що кожен продукт має непорожнє поле `name`
HTTP-виклик має відбутися **один раз** незалежно від кількості тестів, що використовують фікстуру.
# conftest.py
import pytest
import requests
@pytest.fixture(scope='session')
def product_list():
resp = requests.get('https://apilearn.tukas.dev/api/products/')
assert resp.status_code == 200
return resp.json()['results']
# test_product_list.py
def test_products_not_empty(product_list):
assert len(product_list) > 0
def test_products_have_slug(product_list):
assert all(isinstance(p.get('slug'), str) and len(p['slug']) > 0
for p in product_list)
def test_products_have_name(product_list):
assert all(isinstance(p.get('name'), str) and len(p['name']) > 0
for p in product_list)
No split tab
Налаштування cookies
Ми використовуємо необхідні cookies для роботи сайту. З вашого дозволу ми також можемо зберігати налаштування сайту та використовувати аналітичні й рекламні cookies, щоб розуміти використання сайту й підтримувати розвиток проєкту.
* Ви завжди можете змінити свій вибір у налаштуваннях сайту.
Оберіть категорії cookies
Налаштування аналітики
Можна вимкнути аналітику використання платформи. Також можна надіслати в Google Analytics запит на видалення даних про використання цього сайту, пов'язаних із цим браузером.
Навчальний workspace
Навчайтеся, читаючи, запускаючи код і розв'язуючи задачі.
Практикуйте програмування з поясненнями тем, вправами, інструментами browser IDE, перевіркою regex і тренуванням друку коду в одному workspace.
Відкривайте інструменти у вкладках.Вправи, IDE-інструменти й тренажери залишаються доступними як вкладки сайту.
Перемикайтеся без втрати контексту.Переходьте між поясненнями, кодом та утилітами, зберігаючи своє місце.
Використовуйте sidebar як карту.Ліві панелі містять навігацію, налаштування, файли, libraries та керування інструментами.
PythonJavaScriptSQLite
Одна IDE, три практичні режими
Python у браузері.Запускайте невеликі скрипти, пробуйте бібліотеки й тренуйте API-запити без встановлення.
JavaScript для швидких експериментів.Перевіряйте код для браузера й порівнюйте ідеї поруч із навчальними матеріалами.
SQLite для практики з даними.Відкривайте оглядач бази даних, переглядайте таблиці, пишіть запити й вивчайте SQL локально.
ТемаIDE
Працюйте поруч у split tabs
Тримайте інструкції перед очима.Відкрийте вправу або довідкову сторінку поруч з IDE, замість постійних перемикань.
Порівнюйте інструменти під час навчання.Розміщуйте перевірки regex, пояснення та експерименти з кодом поруч, коли це потрібно для задачі.
Закрийте split, коли завершите.Workspace повернеться до однієї сфокусованої вкладки, а відкриті вкладки сайту залишаться доступними.
Тренажер друку коду
Або звичайного тексту
Тренажер розрахований на фізичну клавіатуру.Відкрийте цей розділ на ноутбуці або комп'ютері з широким екраном. На телефоні тренування друку не працюватиме коректно.
Швидкість: 0 зн/хв
0 слів/хв
Найкраща швидкість (60с): 0 зн/хв
0 слів/хв
Помилки: 0
Загальний час: 0.0 с
Щоб тренувати сліпий друк, не підглядайте на фізичну клавіатуру.