## Не все тесты должны запускаться всегда
По мере роста тест-сьюта тесты приобретают очень разные характеристики:
- Одни быстрые (чистые функции, без I/O)
- Другие медленные (обращаются к базе данных или внешнему API)
- Одни имеют смысл только на конкретной ОС или версии Python
- Другие проверяют ещё не реализованную функциональность
- Некоторые заведомо падают из-за известного бага
Запускать всё при каждом нажатии клавиши -- пустая трата времени. **Метки** позволяют семантически помечать тесты и фильтровать их флагом `-m` во время запуска.
## Встроенные метки
pytest поставляется с несколькими встроенными метками. Они не требуют регистрации.
### @pytest.mark.skip -- безусловный пропуск
```python
@pytest.mark.skip(reason='payment gateway not configured in test env')
def test_charge_card():
...
```
Тест показывается как `s` в выводе. С `-v` виден reason. Всегда указывайте `reason=` -- будущий вы скажет спасибо нынешнему.
### @pytest.mark.skipif -- условный пропуск
```python
import sys
@pytest.mark.skipif(sys.platform == 'win32', reason='POSIX paths only')
def test_symlinks():
...
@pytest.mark.skipif(sys.version_info < (3, 11), reason='tomllib added in 3.11')
def test_tomllib_parse():
import tomllib
...
```
Условие вычисляется во время **сбора** (когда pytest собирает тесты), а не во время выполнения. Используйте любое Python-выражение, возвращающее bool.
### @pytest.mark.xfail -- ожидаемое падение
```python
@pytest.mark.xfail(reason='bug #47: parser crashes on empty input')
def test_parse_empty():
assert parse('') == [] # сейчас выбрасывает IndexError
```
Исходы:
- Тест падает -> `x` (xfail -- ожидаемо, всё в порядке)
- Тест проходит -> `X` (xpass -- неожиданное прохождение)
Добавьте `strict=True`, чтобы неожиданное прохождение стало жёсткой ошибкой. Это обязывает удалить метку после исправления бага -- критично для CI:
```python
@pytest.mark.xfail(strict=True, reason='bug #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='not yet implemented')),
])
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 x 3 = 6 тест-кейсов всего
```
---
## Пользовательские метки
Можно определить любое имя метки: `@pytest.mark.slow`, `@pytest.mark.database`, `@pytest.mark.external`. Но их необходимо **зарегистрировать** в `pytest.ini`, чтобы избежать `PytestUnknownMarkWarning`:
```ini
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
database: tests that require a real database connection
external: tests that call external services
```
Применяются как декораторы -- у теста может быть несколько меток:
```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: marks slow-running tests
database: requires a live database
external: calls an external service
```
Без этого pytest показывает `PytestUnknownMarkWarning` для пользовательских меток. Регистрация также позволяет `--markers` выводить их список для всей команды.
## Встроенные метки в тестовом файле
```python
# test_features.py
import sys
import pytest
@pytest.mark.skip(reason='billing module not implemented yet')
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 is Linux-only')
def test_file_watcher():
from watcher import FileWatcher
watcher = FileWatcher('/tmp')
assert watcher.is_running()
@pytest.mark.xfail(reason='bug #88: parse() crashes on empty input')
def test_parse_empty():
from mylib import parse
assert parse('') == [] # сейчас выбрасывает IndexError -- xfail разрешает это
@pytest.mark.xfail(strict=True, reason='bug #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 paths')
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: marks slow tests
database: requires a database
external: calls external services
```
**Применение меток:**
```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
```