Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 13 из 20 Средний 30 мин 130 XP

Метки: skip, xfail и свои mark

Не каждый тест обязан бежать в каждом прогоне: пометь его skip с причиной, xfail для известного бага или своей меткой — и управляй набором как меню, а не как свалкой.

Редакция Питоники

Набор тестов растёт — и вместе с ним растают исключения. Один тест работает только на Linux, второй ждёт функцию, которую ещё не написали, третий падает из-за известного бага, который починят после релиза. Удалять такие тесты нельзя — они вернутся в дело; игнорировать падение — значит приучить себя к красному набору. pytest предлагает третий путь: метки. Строка над тестом — и его судьба в прогоне решена явно, с причиной, которая видна каждому.

Правило приличного набора: пропуски и ожидаемые падения — явные и с причиной. Тогда сводка остаётся честной картиной: если из тридцати тестов один skipped и один xfailed, ты знаешь об этом и знаешь почему. Молчаливые исключения неуправляемы: они всплывают то у одного разработчика, то у другого, и каждый раз собирают свою версию правды.

skip: тест вне сегодняшнего прогона

Метка @pytest.mark.skip выводит тест из прогона совсем, а skipif — только при выполнении условия. Обязательный аргумент reason объясняет отчёту, почему тест отдыхает:

skip и skipif в одном файле
from pathlib import Path

Path("test_parser.py").write_text("""
import sys

import pytest

@pytest.mark.skip(reason="парсер ещё не написан")
def test_parse_table():
    assert parse_table("x,y") == [("x", "y")]

@pytest.mark.skipif(sys.version_info < (3, 10), reason="нужен Python 3.10+")
def test_modern_syntax():
    assert 2 + 2 == 4

def test_split():
    assert "a b".split() == ["a", "b"]
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_parser.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
s..                                                                      [100%]
2 passed, 1 skipped in 0.01s
exit: 0

Первый символ строки результатов — s: тест parse_table пропущен, остальные два выполнены. Сводка честная: 2 passed, 1 skipped, и код выхода 0 — пропуск не красит набор. Логика правильная: skip-тест — не ошибка прогона, а осознанное решение, зафиксированное reason-ом.

Разница между метками в области действия: skip отключает тест всегда, skipif — по обстоятельствам. Условие в skipif вычисляется сразу при сборке: sys.version_info < (3, 10) — правда на старых Python, ложь на новых, и тест сам решает, где ему бежать. Так переносят платформенно-зависимые проверки и тесты для новых версий языка.

причина пропуска видна в -v
============================= test session starts =============================
collecting ... collected 3 items

test_parser.py::test_parse_table SKIPPED (парсер ещё не написан)         [ 33%]
test_parser.py::test_modern_syntax PASSED                                [ 66%]
test_parser.py::test_split PASSED                                        [100%]

======================== 2 passed, 1 skipped in 0.02s =========================
exit: 0
Тот же файл в подробном режиме: причина из reason попадает прямо в строку отчёта. Коллега, увидевший SKIPPED (парсер ещё не написан), не побежит спрашивать, что случилось.

Причина в метке — это не формальность, а документ, который читают люди. Хороший reason называет и факт («не работает на Windows»), и отсылку («задача №412») — тогда решение о пропуске можно проверить, а не принять на веру. Бестолковое «не работает» через месяц заставит разбираться заново.

skip внутри теста: решение на ходу

Иногда условие пропуска знает только сам тест: метка вычисляется до запуска, а решение нужно после пары проверок. Для этого тесты вызывают pytest.skip() прямо из тела — выполнение прекращается, тест получает тот же символ s:

pytest.skip по ходу теста
from pathlib import Path

Path("test_export.py").write_text("""
import sys

import pytest

def export_data():
    return "ok"

def test_export():
    if sys.platform != "linux":
        pytest.skip("экспорт работает только на linux")
    assert export_data() == "ok"
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_export.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
s                                                                        [100%]
1 skipped in 0.01s
exit: 0

На платформе с sys.platform, отличной от linux, тест пропускает сам себя — на нашей машине так и вышло. Причина, переданная pytest.skip, попадает в отчёт так же, как reason метки. Формы дополняют друг друга: метка — когда условие известно до запуска, вызов — когда решение рождается внутри теста, например после проверки содержимого файла.

xfail: ожидаемо падающий тест

Особая категория — тесты на известные баги: функция скидок ещё не умеет проценты, и тест на скидки падает. Писать его всё равно надо — это спецификация будущего поведения, — но падение мешает. @pytest.mark.xfail помечает тест как «ожидаемо падающий»:

тест на ещё не написанную скидку
from pathlib import Path

Path("test_cart.py").write_text("""
import pytest

def total(prices):
    return sum(prices)

@pytest.mark.xfail(reason="скидки ещё не реализованы")
def test_total_with_discount():
    assert total([100, 200]) == 270
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_cart.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
x                                                                        [100%]
1 xfailed in 0.05s
exit: 0

Тест честно упал — и набор зелёный: pytest засчитал падение как ожидаемое, символ x, сводка 1 xfailed, код выхода 0. Это документация бага в исполняемом виде: строка над тестом и reason рассказывают, что происходит и почему. Когда скидки реализуют, картина переменится — и pytest не даст этого пропустить.

Отдельный вопрос — пометить целый файл тестов, а не один. Для этого в начале модуля объявляют переменную pytestmark: одна строка — и все тесты файла получают метку. Так помечают модуль, целиком зависящий от недоступного сервиса:

метка на весь файл тестов
# в начале файла test_migration.py, до импортов тестов
pytestmark = pytest.mark.skip(reason="модуль ждёт миграцию базы, задача №412")
pytestmark — специальное имя уровня модуля: pytest применяет метку ко всем тестам файла. Смешивать метки можно списком: pytestmark = [pytest.mark.slow, pytest.mark.integration].

Модульная метка — инструмент для больших перестроек: миграции базы, переезда на новую версию API. Каждый отдельный тест помечать утомительно и опасно — забышь на одном; pytestmark помечает файл целиком и так же целиком снимается.

скидки реализовали, метку забыли снять
X                                                                       [100%]
1 xpassed in 0.02s
exit: 0
Настоящий отчёт pytest 9: тест под xfail внезапно прошёл. Прописная X и сводка 1 xpassed — сигнал: баг починен, метка устарела, пора её снимать.

Почему для известного бага xfail лучше, чем skip? Skip отключает проверку целиком: починят функцию — тест по-прежнему будет спать, и регресс никто не заметит. Xfail продолжает гонять тело теста: пока баг жив, набор зелёный, но первый же успех становится событием xpassed, которое невозможно пропустить. Проверка сохранена, а шум от известного падения убран.

Свои метки и отбор через -m

Метки можно придумывать свои — slow, integration, smoke — и вешать на целые группы: помеченный тест отбирается флагом -m при запуске. Одно «но»: свою метку надо зарегистрировать, иначе pytest предупредит PytestUnknownMarkWarning — он подозревает опечатку в имени. Регистрация живёт в conftest.py, файле настроек, который pytest читает рядом с тестами — подробно о нём урок 15:

метка slow и отбор «всё, кроме медленных»
from pathlib import Path

Path("conftest.py").write_text("""
def pytest_configure(config):
    config.addinivalue_line("markers", "slow: долгие тесты, гоняем по вечерам")
""", encoding="utf-8")

Path("test_search.py").write_text("""
import pytest

@pytest.mark.slow
def test_full_index():
    assert sum(range(1000)) == 499500

def test_quick_lookup():
    assert "a" in "abc"
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_search.py", "-m", "not slow", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
.                                                                        [100%]
1 passed, 1 deselected in 0.01s
exit: 0

Флаг -m принимает выражение: not slow отобрал быстрый тест, а медленный попал в deselected — его даже не запускали. Выражения понимают и, или, not: -m "slow or integration", -m "not slow and not integration". Развернём выбор наоборот — возьмём только медленные:

запускаем только медленные
rc = pytest.main(["test_search.py", "-m", "slow", "-q", "--no-header", "-p", "no:cacheprovider"])
Вывод
.                                                                       [100%]
1 passed, 1 deselected in 0.00s
exit: 0
Тот же файл, тот же conftest: -m slow выбрал полный индекс и отсёк быстрый тест. Так slow-набор гоняют по вечерам, а быстрый — на каждый коммит.

Практический распорядок для команды: быстрые тесты — на каждый коммит, метка slow — на вечерние прогоны, integration — на окружение с настоящими сервисами. Метки превращают один набор в несколько меню без дублирования кода. А ещё метки отлично сочетаются с параметризацией: помеченный параметризованный тест пропускает или отбирается всей таблицей разом.

importorskip: тест без обязательного пакета

Частый случай пропуска — необязательная зависимость: тест проверяет интеграцию с библиотекой, которая установлена не везде. Функция pytest.importorskip пытается импортировать пакет и при неудаче пропускает тест с причиной:

тест, который сам решает, есть ли пакет
from pathlib import Path

Path("test_optional.py").write_text("""
import pytest

def test_with_optional_lib():
    pytest.importorskip("definitely_absent_package", reason="пакет не обязателен")
    assert True
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_optional.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
s                                                                        [100%]
1 skipped in 0.01s
exit: 0

Пакета нет ни на одной нормальной машине, поэтому тест честно пропущен: importorskip вернул skip вместо ImportError. Там, где пакет стоит, тот же тест выполняется. Это правильная альтернатива двум крайностям — не ставить зависимость всем и не ронять набор ImportError-ом у тех, кто её не ставил. Та же функция принимает минимум версии: параметр minversion пропустит тест и там, где пакет установлен, но слишком стар.

Меток на одном тесте может быть сколько угодно — декораторы складываются: и slow, и integration одновременно. Отбор в -m учитывает их все: тест с двумя метками попадёт в выборку по любой из них. Складывай метки по смыслу, а не про запас: каждая лишняя метка — лишний способ случайно исключить тест из прогона.

МеткаЧто делаетСимвол и сводка
skip(reason)не запускает тест вообщеs, 1 skipped
skipif(условие)не запускает при верном условииs, 1 skipped
xfail(reason)запускает, падение считается нормойx, 1 xfailed
xfail + тест прошёлсигнал снять меткуX, 1 xpassed
своя метка + -mотбор групп тестовdeselected для непопавших

И дисциплина напоследок: метки работают на набор, только пока их меньшинство. Если skip-тестов треть, значит, набор описывает проект мечты, а не настоящий код, — и красные участки правильнее чинить, а не метить. Здоровый набор: подавляющее большинство точек, считанные s и x — каждый с причиной и сроком.

Что дальше

Ты собрал полный алфавит отчёта и научился управлять судьбой теста: skip убирает его из прогона с причиной, skipif добавляет условие, xfail легализует известный баг, а свои метки с -m режут набор по назначению. Метки — это ещё и договор с будущим: у каждого skip и xfail есть причина и срок. Дальше — продвинутая параметризация: читаемые имена кейсов через ids, стекирование нескольких parametrize и таблицы граничных значений. А conftest.py из урока 15 подхватит и регистрацию меток, и общие фикстуры.

Пропущенный тест без причины — это не пропуск, а дыра: reason в метке объясняет набору, почему сегодня тест отдыхает.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

marks = ["skip", "pass", "xfail"]
symbols = {"skip": "s", "pass": ".", "xfail": "x"}

line = "".join(symbols[m] for m in marks)
print(line)
print("passed:", marks.count("pass"), "skipped:", marks.count("skip"))
conditions = [
    ("linux", True),
    ("windows", False),
]

for name, only_linux in conditions:
    if only_linux:
        print(name, "-> s")
    else:
        print(name, "-> .")
Проверь себя
0 / 6

1. Что означает символ s в строке результатов pytest?

2. Чем skipif отличается от skip?

3. Тест под @pytest.mark.xfail внезапно прошёл. Что покажет отчёт?

4. Зачем регистрировать свою метку вроде slow в conftest.py?

5. Что сделает pytest -m "not slow"?

6. Изменяет ли skip код выхода прогона?

Карточки терминов
Запомнено: 0 / 7
Практика

Собери файл из трёх тестов сложения: test_positive проверяет 2 + 2 == 4, test_negative помечен @pytest.mark.skip с причиной «отрицательные числа не поддерживаются», test_zero проверяет 0 + 0 == 0. Запусти файл и напечатай код выхода.

practice.py
Вопросы и ответы по уроку

Как пропустить тест в pytest?

Меткой @pytest.mark.skip(reason="причина") над тестом — он не запустится ни на какой машине, а в отчёте получит символ s и сводку 1 skipped. Для условного пропуска есть @pytest.mark.skipif(условие, reason="..."): тест выполняется только там, где условие ложно. Пропуск не роняет прогон — код выхода остаётся 0.

Что означает xfail в pytest?

@pytest.mark.xfail помечает тест, который ожидаемо падает, — обычно из-за известного бага. pytest запускает его, и падение засчитывается как норма: символ x, сводка 1 xfailed, набор зелёный. Если тест вдруг пройдёт, появится X и 1 xpassed — сигнал, что баг починен и метку пора снять.

Как запустить только часть тестов по метке?

Флагом -m: pytest -m slow запускает только тесты с меткой slow, pytest -m "not slow" — всё, кроме них. Выражения комбинируются: -m "slow or integration". Свои метки нужно зарегистрировать в conftest.py через pytest_configure, иначе pytest предупредит PytestUnknownMarkWarning.

Почему pytest ругается на Unknown pytest.mark.slow?

Это PytestUnknownMarkWarning: метка slow не зарегистрирована, и pytest подозревает опечатку — незнакомая метка молча выведет тест из отбора -m. Зарегистрируй метку в conftest.py: def pytest_configure(config): config.addinivalue_line("markers", "slow: описание"). Предупреждение исчезнет, а метки получат документацию в одном месте.

Чем xfail отличается от skip?

skip не запускает тест вообще — его тело не выполняется. xfail запускает тест по-настоящему и лишь прощает ему падение: если функция начнёт работать раньше срока, pytest заметит это в xpassed. Поэтому для известных багов правильный выбор — xfail: он продолжает проверять код, а не отключает проверку совсем.

Понравился урок? Сошлитесь на него

«Это документация бага в исполняемом виде: строка над тестом и reason рассказывают, что происходит и почему.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.