Метки: skip, xfail и свои mark
Не каждый тест обязан бежать в каждом прогоне: пометь его skip с причиной, xfail для известного бага или своей меткой — и управляй набором как меню, а не как свалкой.
Редакция Питоники
Набор тестов растёт — и вместе с ним растают исключения. Один тест работает только на Linux, второй ждёт функцию, которую ещё не написали, третий падает из-за известного бага, который починят после релиза. Удалять такие тесты нельзя — они вернутся в дело; игнорировать падение — значит приучить себя к красному набору. pytest предлагает третий путь: метки. Строка над тестом — и его судьба в прогоне решена явно, с причиной, которая видна каждому.
Правило приличного набора: пропуски и ожидаемые падения — явные и с причиной. Тогда сводка остаётся честной картиной: если из тридцати тестов один skipped и один xfailed, ты знаешь об этом и знаешь почему. Молчаливые исключения неуправляемы: они всплывают то у одного разработчика, то у другого, и каждый раз собирают свою версию правды.
skip: тест вне сегодняшнего прогона
Метка @pytest.mark.skip выводит тест из прогона совсем, а skipif — только при выполнении условия. Обязательный аргумент reason объясняет отчёту, почему тест отдыхает:
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, ложь на новых, и тест сам решает, где ему бежать. Так переносят платформенно-зависимые проверки и тесты для новых версий языка.
============================= 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 называет и факт («не работает на Windows»), и отсылку («задача №412») — тогда решение о пропуске можно проверить, а не принять на веру. Бестолковое «не работает» через месяц заставит разбираться заново.
skip внутри теста: решение на ходу
Иногда условие пропуска знает только сам тест: метка вычисляется до запуска, а решение нужно после пары проверок. Для этого тесты вызывают pytest.skip() прямо из тела — выполнение прекращается, тест получает тот же символ s:
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")
Модульная метка — инструмент для больших перестроек: миграции базы, переезда на новую версию API. Каждый отдельный тест помечать утомительно и опасно — забышь на одном; pytestmark помечает файл целиком и так же целиком снимается.
X [100%]
1 xpassed in 0.02s
exit: 0
Почему для известного бага xfail лучше, чем skip? Skip отключает проверку целиком: починят функцию — тест по-прежнему будет спать, и регресс никто не заметит. Xfail продолжает гонять тело теста: пока баг жив, набор зелёный, но первый же успех становится событием xpassed, которое невозможно пропустить. Проверка сохранена, а шум от известного падения убран.
Свои метки и отбор через -m
Метки можно придумывать свои — slow, integration, smoke — и вешать на целые группы: помеченный тест отбирается флагом -m при запуске. Одно «но»: свою метку надо зарегистрировать, иначе pytest предупредит PytestUnknownMarkWarning — он подозревает опечатку в имени. Регистрация живёт в conftest.py, файле настроек, который pytest читает рядом с тестами — подробно о нём урок 15:
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
Практический распорядок для команды: быстрые тесты — на каждый коммит, метка 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, "-> .")
1. Что означает символ s в строке результатов pytest?
2. Чем skipif отличается от skip?
3. Тест под @pytest.mark.xfail внезапно прошёл. Что покажет отчёт?
4. Зачем регистрировать свою метку вроде slow в conftest.py?
5. Что сделает pytest -m "not slow"?
6. Изменяет ли skip код выхода прогона?
Собери файл из трёх тестов сложения: test_positive проверяет 2 + 2 == 4, test_negative помечен @pytest.mark.skip с причиной «отрицательные числа не поддерживаются», test_zero проверяет 0 + 0 == 0. Запусти файл и напечатай код выхода.
Как пропустить тест в 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 12
monkeypatch: подмена функций и окружения в тестах
Функция ходит во внешний мир — за курсом валют, случайностью, переменными окружения — и тест теряет управление. monkeypatch подменяет внешний мир заглушкой на время теста и убирает её сам.
pytest · Урок 14
Параметризация продвинутая: ids и несколько наборов
Таблице кейсов не хватает двух вещей: имён вместо безликих чисел в отчёте и комбинаций нескольких наборов. Оба закрываются аргументом ids= и стекированием декораторов.
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestpytest fixture test_client
pytest · Урок 8
Параметризация: @pytest.mark.parametrize
Десять одинаковых тестов для десяти случаев — это десять копипаст. parametrize сжимает их в один тест и список кортежей, а pytest разворачивает обратно в десять отчётных строк.
pytest параметризацияpytest parametrize