## Не всі тести мають завжди запускатися
Коли тест-сьют розростається, тести набувають дуже різних характеристик:
- Одні швидкі (чисті функції, без I/O)
- Інші повільні (звертаються до БД або зовнішнього API)
- Деякі мають сенс лише на конкретній ОС або версії Python
- Деякі тестують функцію, яка ще не реалізована
- Деякі відомо провалюються через відкритий баг
Запускати все на кожному натисканні клавіші -- марна витрата часу. **Мітки** дозволяють семантично позначати тести і фільтрувати їх прапором `-m` під час запуску.
## Вбудовані мітки
pytest поставляється з кількома мітками з коробки. Вони не потребують реєстрації.
### @pytest.mark.skip -- безумовний пропуск
```python
@pytest.mark.skip(reason='платіжний шлюз не налаштований у тестовому середовищі')
def test_charge_card():
...
```
Тест відображається як `s` у виводі. З `-v` видно причину. Завжди вказуйте `reason=` -- майбутній ви подякує теперішньому.
### @pytest.mark.skipif -- умовний пропуск
```python
import sys
@pytest.mark.skipif(sys.platform == 'win32', reason='лише POSIX-шляхи')
def test_symlinks():
...
@pytest.mark.skipif(sys.version_info < (3, 11), reason='tomllib додано у 3.11')
def test_tomllib_parse():
import tomllib
...
```
Умова обчислюється під час **збору** (коли pytest збирає тести), а не під час запуску. Використовуйте будь-який вираз Python, що обчислюється як bool.
### @pytest.mark.xfail -- очікуваний збій
```python
@pytest.mark.xfail(reason='баг #47: парсер падає на порожньому введенні')
def test_parse_empty():
assert parse('') == [] # наразі підіймає IndexError
```
Результати:
- Тест провалюється → `x` (xfail -- очікувано, нормально)
- Тест проходить → `X` (xpass -- неочікуваний успіх)
Додайте `strict=True`, щоб неочікуваний успіх став жорсткою помилкою. Це змушує видалити мітку після виправлення бага -- критично для CI:
```python
@pytest.mark.xfail(strict=True, reason='баг #47')
def test_parse_empty():
assert parse('') == []
```
З `strict=True`:
- Тест продовжує провалюватися як очікувано → `x` (так само, як без `strict`)
- Тест несподівано проходить → `FAILED` -- блокує CI так само, як звичайний провал тесту
Без `strict=True` неочікуваний успіх відображається як `X` (xpass) -- попередження, а не збій.
## @pytest.mark.parametrize -- запуск одного тесту з багатьма вхідними даними
Найбільш вживана мітка. Замість написання п'яти майже однакових тестів -- напишіть один і надайте список вхідних даних:
```python
import pytest
@pytest.mark.parametrize('value,expected', [
(0, 0),
(1, 1),
(-1, 1),
(100, 100),
(-50, 50),
])
def test_abs(value, expected):
assert abs(value) == expected
```
pytest запускає цей тест п'ять разів, по одному на кожен рядок. Кожен запуск відображається як окремий елемент у виводі: `test_abs[0-0]`, `test_abs[1-1]` тощо.
### Один параметр
```python
@pytest.mark.parametrize('n', [1, 2, 3, 10, 100])
def test_positive(n):
assert n > 0
```
### Позначення окремих кейсів
Кожен набір параметрів може мати власні мітки. Корисно для пропуску одного кейсу або позначення його як очікуваного збою:
```python
@pytest.mark.parametrize('code,expected_status', [
('SAVE20', 200),
('EXPIRED', 400),
pytest.param('SECRET', 200, marks=pytest.mark.xfail(reason='ще не реалізовано')),
])
def test_coupon(code, expected_status):
...
```
### Стекування parametrize
Кілька декораторів `@pytest.mark.parametrize` на одному тесті дають **декартовий добуток** -- тестується кожна комбінація:
```python
@pytest.mark.parametrize('base', [10, 100])
@pytest.mark.parametrize('discount', [0.1, 0.2, 0.5])
def test_discount(base, discount):
result = base * (1 - discount)
assert result < base # 2 × 3 = 6 тест-кейсів всього
```
---
## Власні мітки
Ви можете визначити будь-яке ім'я мітки: `@pytest.mark.slow`, `@pytest.mark.database`, `@pytest.mark.external`. Але їх необхідно **зареєструвати** у `pytest.ini`, щоб уникнути `PytestUnknownMarkWarning`:
```ini
[pytest]
markers =
slow: позначає повільні тести (відмінити з '-m "not slow"')
database: тести, що потребують реального підключення до БД
external: тести, що звертаються до зовнішніх сервісів
```
Застосовуйте їх як декоратори -- тест може мати кілька міток:
```python
@pytest.mark.slow
@pytest.mark.external
def test_load_from_s3():
...
```
## Запуск тестів за міткою: вирази -m
```bash
pytest -m slow # лише тести 'slow'
pytest -m "not slow" # все, крім 'slow'
pytest -m "slow and external" # мають бути обидві мітки
pytest -m "slow or database" # будь-яка з міток
pytest -m "not (slow or external)" # жодна з міток
```
Прапор `-m` підтримує `and`, `or`, `not` і дужки -- повна булева алгебра.
## Практичний шаблон розробки
Зареєструйте мітку `slow`. Застосовуйте її до всього, що звертається до мережі або реальної БД. Під час розробки запускайте `pytest -m "not slow"` для зворотного зв'язку за частки секунди. У CI запускайте `pytest` без фільтра, щоб охопити все, включаючи повільні тести.
Цей шаблон тримає локальні ітерації швидкими, забезпечуючи повне покриття в CI без жодних відмінностей у конфігурації -- ті самі файли тестів, різні прапори `-m`.
## Налаштування: реєстрація власних міток у pytest.ini
```ini
# pytest.ini
[pytest]
markers =
slow: позначає повільні тести
database: потребує живої БД
external: звертається до зовнішнього сервісу
```
Без цього pytest показує `PytestUnknownMarkWarning` для власних міток. Реєстрація також дозволяє `--markers` виводити їх для команди.
## Вбудовані мітки у тестовому файлі
```python
# test_features.py
import sys
import pytest
@pytest.mark.skip(reason='модуль виставлення рахунків ще не реалізовано')
def test_generate_invoice():
from billing import generate_invoice
assert generate_invoice(order_id=1) is not None
@pytest.mark.skipif(sys.platform != 'linux', reason='inotify лише для Linux')
def test_file_watcher():
from watcher import FileWatcher
watcher = FileWatcher('/tmp')
assert watcher.is_running()
@pytest.mark.xfail(reason='баг #88: parse() падає на порожньому введенні')
def test_parse_empty():
from mylib import parse
assert parse('') == [] # наразі підіймає IndexError -- xfail це дозволяє
@pytest.mark.xfail(strict=True, reason='баг #88')
def test_parse_empty_strict():
# Коли баг #88 буде виправлено і тест пройде, CI провалиться, поки мітку не видалять
from mylib import parse
assert parse('') == []
```
## Власні мітки: розділення швидких і повільних тестів
```python
# test_products.py
import pytest
import requests
BASE_URL = 'https://apilearn.tukas.dev'
def test_price_calculation():
# Швидкий -- чиста математика, без I/O; запускається при кожному локальному pytest
assert round(100 * 0.9, 2) == 90.0
@pytest.mark.slow
def test_products_api():
# Реальний HTTP-запит -- відмінити з '-m "not slow"' під час розробки
resp = requests.get(f'{BASE_URL}/api/products/')
assert resp.status_code == 200
@pytest.mark.slow
@pytest.mark.external
def test_product_search():
# Дві мітки -- можна цілитися з '-m "slow and external"'
resp = requests.get(f'{BASE_URL}/api/products/?search=laptop')
assert resp.status_code == 200
```
## Запуск з -m
```bash
# Розробка: миттєвий зворотний зв'язок, пропустити все повільне
pytest -m "not slow"
# CI: все, включаючи повільні та зовнішні тести
pytest
# Цільова група
pytest -m "slow and not external"
# Показати причини пропущених тестів
pytest -v -m "not slow"
```
**Вивід з `pytest -v -m "not slow"`:**
```
test_products.py::test_price_calculation PASSED
test_products.py::test_products_api DESELECTED
test_products.py::test_product_search DESELECTED
1 passed, 2 deselected in 0.05s
```
## Застосування мітки до цілого класу
```python
@pytest.mark.slow
class TestAPIIntegration:
def test_products(self): ...
def test_orders(self): ... # обидва автоматично отримують 'slow'
```
## Поєднання з skipif
```python
@pytest.mark.slow
@pytest.mark.skipif(sys.platform == 'win32', reason='Unix-шляхи')
def test_unix_file_processing():
...
```
Мітки стекуються -- всі умови застосовуються незалежно.
**Вбудовані мітки:**
| Мітка | Ефект | Вивід |
|-------|-------|-------|
| `@pytest.mark.skip(reason='...')` | Завжди пропустити | `s` |
| `@pytest.mark.skipif(cond, reason='...')` | Пропустити, якщо умова True | `s` |
| `@pytest.mark.xfail(reason='...')` | Очікуваний збій | `x` / `X` |
| `@pytest.mark.xfail(strict=True)` | Неочікуваний успіх = збій CI | `F` |
**Реєстрація власних міток (pytest.ini):**
```ini
[pytest]
markers =
slow: позначає повільні тести
database: потребує БД
external: звертається до зовнішніх сервісів
```
**Застосування міток:**
```python
@pytest.mark.slow
@pytest.mark.database
def test_something(): ...
```
**Застосування до цілого класу:**
```python
@pytest.mark.slow
class TestHeavy:
def test_a(self): ...
def test_b(self): ... # обидва отримують 'slow'
```
**Синтаксис виразів -m:**
```bash
pytest -m slow
pytest -m "not slow"
pytest -m "slow and database"
pytest -m "slow or external"
pytest -m "not (slow or external)"
```
**Поширені шаблони skipif:**
```python
import sys
@pytest.mark.skipif(sys.platform == 'win32', reason='...')
@pytest.mark.skipif(sys.version_info < (3, 11), reason='...')
```
**Перелік усіх зареєстрованих міток:**
```bash
pytest --markers
```