Python · Тестирование с pytest · Средний

Маркеры и выбор тестов

5 задач

Помечайте тесты встроенными и пользовательскими маркерами для управления запуском.

Метки и выбор тестов

#
## Не все тесты должны запускаться всегда По мере роста тест-сьюта тесты приобретают очень разные характеристики: - Одни быстрые (чистые функции, без 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 ```
01

Пропуск теста с указанием причины

#

Напишите тест-функцию `test_future_feature`, которая проверяет ещё не реализованную функциональность. Пометьте её `@pytest.mark.skip` с описательной строкой `reason`. Запустите `pytest -v` и убедитесь, что тест показывается как `SKIPPED` с видимой причиной. Затем запустите `pytest -v -rs`, чтобы увидеть причину пропуска в разделе итогов.

import pytest


@pytest.mark.skip(reason='...')   # укажите вашу причину здесь
def test_future_feature():
    # этот код никогда не выполняется при пропуске
    result = some_unimplemented_function()
    assert result == 42
Решение
import pytest


@pytest.mark.skip(reason='payment refunds not implemented yet -- see issue #42')
def test_future_feature():
    result = some_unimplemented_function()
    assert result == 42
02

Пропуск на основе версии Python

#

Напишите тест, использующий `@pytest.mark.skipif` для пропуска при версии Python ниже 3.10. Внутри тела теста используйте синтаксис оператора `match` (добавленного в Python 3.10): `match value: case 1: result = "one"`. Запустите `pytest -v` -- тест должен выполняться на Python 3.10+ и пропускаться на более ранних версиях. Выведите `sys.version` в тесте, чтобы подтвердить, какая версия его запустила.

import sys
import pytest


@pytest.mark.skipif(
    # условие: sys.version_info < (?, ?),
    # reason='...',
)
def test_match_statement():
    value = 1
    # используйте здесь оператор match/case
    # assert result == 'one'
Решение
import sys
import pytest


@pytest.mark.skipif(
    sys.version_info < (3, 10),
    reason='match statement added in Python 3.10',
)
def test_match_statement():
    print(f'running on Python {sys.version}')
    value = 1
    match value:
        case 1:
            result = 'one'
        case _:
            result = 'other'
    assert result == 'one'
03

Пометить тест как ожидаемо падающий (xfail)

#

Напишите два теста: `test_known_bug` и `test_known_bug_strict`. Оба вызывают `int("abc")` без try/except (это выбрасывает `ValueError`). Пометьте `test_known_bug` через `@pytest.mark.xfail`, а `test_known_bug_strict` через `@pytest.mark.xfail(strict=True)`. Запустите `pytest -v` -- оба должны показываться как `XFAIL`. Обратите внимание на разницу: без `strict` неожиданное прохождение показывается как `XPASS` (предупреждение, не ошибка); с `strict=True` неожиданное прохождение становится жёстким `FAILED`, блокирующим CI.

import pytest


@pytest.mark.xfail(reason=...)  # укажите причину
def test_known_bug():
    result = int('abc')
    assert result == 0


@pytest.mark.xfail(strict=..., reason=...)  # добавьте параметр strict=
def test_known_bug_strict():
    result = int('abc')
    assert result == 0
Решение
import pytest


@pytest.mark.xfail(reason='bug #77: int() does not handle letters')
def test_known_bug():
    result = int('abc')
    assert result == 0


# С strict=True: если тест неожиданно пройдёт, pytest сообщит об FAILED
@pytest.mark.xfail(strict=True, reason='bug #77')
def test_known_bug_strict():
    result = int('abc')
    assert result == 0
04

Регистрация пользовательской метки и фильтрация через -m

#

Добавьте `slow` в секцию `markers` в `pytest.ini`. Напишите три тест-функции в одном файле: `test_fast_calculation` (без метки, просто `assert 2 + 2 == 4`), `test_slow_api_call` и `test_slow_db_query` (обе помечены `@pytest.mark.slow`). Запустите `pytest -m slow -v` и убедитесь, что выполняются только два медленных теста. Запустите `pytest -m "not slow" -v` и убедитесь, что выполняется только быстрый тест.

# pytest.ini
# [pytest]
# markers =
#     slow: marks tests as slow (deselect with '-m "not slow"')


# test_mixed.py
import pytest


def test_fast_calculation():
    assert 2 + 2 == 4


@pytest.mark.slow
def test_slow_api_call():
    # имитация медленной операции
    import time
    time.sleep(0.1)
    assert True


@pytest.mark.slow
def test_slow_db_query():
    import time
    time.sleep(0.1)
    assert True
Решение
# pytest.ini
# [pytest]
# markers =
#     slow: marks tests as slow (deselect with '-m "not slow"')


# test_mixed.py
import pytest


def test_fast_calculation():
    assert 2 + 2 == 4


@pytest.mark.slow
def test_slow_api_call():
    import time
    time.sleep(0.1)
    assert True


@pytest.mark.slow
def test_slow_db_query():
    import time
    time.sleep(0.1)
    assert True
05

Комбинирование нескольких меток

#

Зарегистрируйте метки `slow` и `unix_only` в `pytest.ini`. Напишите `test_fast_check` без меток. Напишите `test_file_permissions`, помеченный `@pytest.mark.slow`, `@pytest.mark.unix_only` И `@pytest.mark.skipif(sys.platform == 'win32', ...)`. Запустите `pytest -v`, чтобы убедиться, что оба теста выполняются (или пропускаются на Windows). Затем запустите `pytest -m slow -v` -- выбирается только медленный тест. Затем `pytest -m unix_only -v` -- тот же результат. Обратите внимание: `@pytest.mark.skipif` обеспечивает проверку во время выполнения, а метка -- фильтрацию через CLI.

# pytest.ini
# [pytest]
# markers =
#     slow: slow tests
#     unix_only: runs only on Unix/Linux/macOS


import sys
import pytest


def test_fast_check():
    assert 'py' in 'pytest'


@pytest.mark.slow
@pytest.mark.unix_only
@pytest.mark.skipif(sys.platform == 'win32', reason='uses POSIX file permissions')
def test_file_permissions():
    import os
    import tempfile
    with tempfile.NamedTemporaryFile() as f:
        os.chmod(f.name, 0o644)
        mode = oct(os.stat(f.name).st_mode)[-3:]
        assert mode == '644'
Решение
# pytest.ini
# [pytest]
# markers =
#     slow: slow tests
#     unix_only: runs only on Unix/Linux/macOS


import sys
import pytest


def test_fast_check():
    assert 'py' in 'pytest'


@pytest.mark.slow
@pytest.mark.unix_only
@pytest.mark.skipif(sys.platform == 'win32', reason='uses POSIX file permissions')
def test_file_permissions():
    import os
    import tempfile
    with tempfile.NamedTemporaryFile() as f:
        os.chmod(f.name, 0o644)
        mode = oct(os.stat(f.name).st_mode)[-3:]
        assert mode == '644'