Идиомы pytest: переписывание assert, обнаружение тестов, маркеры и петля отладки
pytest переписывает assert через AST-хук импорта, только в собираемых файлах — хелперам нужен register_assert_rewrite. Сбор импортирует каждый тест-модуль, поэтому один синглтон уровня импорта валит весь прогон. raises(match=), строгий xfail, -k/-x/--lf и коды выхода — петля CI.
Пятница, 16:40. Коллега переименовывает дефолт конфига в app/settings.py. Следующий прогон CI — на постороннем PR, который менял только документацию, — красный: не один упавший тест, а 1 847 ошибок за 11 секунд. Не выполнилось вообще ничего. Во время сбора pytest импортирует каждый найденный test_*.py, а tests/payments/test_refunds.py начинался с from app.db import engine — синглтона уровня модуля, который строил пул соединений и пинговал Postgres в момент импорта файла. Переименование настройки сменило DSN, пинг поднял исключение, и одна ошибка импорта на этапе сбора взяла весь сьют в заложники: все PR заблокированы, релизный поезд стоит, а дежурный инженер час читал трейсбек, указывающий на SQLAlchemy вместо однострочной правки настроек. Сьют два года был заряженным ружьём: любой тест-модуль, трогающий синглтон приложения при импорте, ставит все тесты в зависимость от успеха этого импорта — раньше, чем хоть один assert успеет сработать.
Почему голый assert лучше self.assertEqual
Задайте себе вопрос: почему unittest пишет AssertionError: False is not true, а pytest показывает каждое подвыражение — ответ определяет, как вы организуете каждую общую проверку в кодовой базе.
pytest на старте устанавливает хук импорта (meta-path finder). Когда он импортирует модуль, который собирается коллектить — тест-модуль, conftest.py или зарегистрированный плагин, — он разбирает исходник в AST, переписывает каждый оператор assert в код, записывающий значение каждого подвыражения, перекомпилирует и кэширует переписанный байткод в специально помеченном .pyc. В этом весь фокус знаменитого вывода при падении:
# test_pricing.py — собирается, поэтому assert ниже переписан при импорте
def apply_discount(cents: int, pct: int) -> int:
return cents * (100 - pct) // 100
def test_discount():
assert apply_discount(1999, 15) == 1700
# E assert 1699 == 1700
# E + where 1699 = apply_discount(1999, 15)Не нужно помнить assert-методы, не нужен self.assertEqual(a, b) — переписанный код восстанавливает, во что вычислилась каждая сторона. Команды кусает граница: переписываются только модули, которые pytest сам импортирует для сбора. Вынесите общие проверки в tests/helpers.py — assert там по-прежнему поднимется при провале, но как голый AssertionError без значений: диагностика исчезла ровно там, где вы её централизовали. Фикс — одна строка, размещённая до первого импорта хелпера (стандартное место — корневой conftest.py): pytest.register_assert_rewrite("tests.helpers"). Два смежных факта: файлы conftest.py переписываются автоматически, а запуск Python с -O вырезает операторы assert целиком — сьют под -O проходит вхолостую, поэтому pytest об этом предупреждает.
Общие проверки вынесли в tests/helpers.py: `def check_order(o): assert o.total == sum(i.price for i in o.items)`. Падения теперь показывают голый AssertionError без значений. Почему?
Обнаружение: сначала импорты, ваш код выполняется независимо от желания
Получив целевую директорию, pytest собирает файлы по маскам test_*.py и *_test.py; внутри них — функции с префиксом test_ и классы с префиксом Test, не определяющие __init__. Перед импортом тест-модуля он импортирует цепочку conftest.py от rootdir вниз до директории модуля — так фикстуры и хуки становятся доступны без импортов. Структурное следствие — инцидент из вступления: сбор означает импорт. Всё, что выполняется на уровне модуля в тест-файле — или в чём-то, что он транзитивно импортирует, — выполняется до запуска тестов. Один модуль, строящий движок БД, читающий отсутствующую переменную окружения или создающий синглтон приложения при импорте, даёт ошибку сбора; pytest печатает Interrupted: 1 error during collection и не запускает ничего. Падающий conftest.py хуже: он отравляет всё своё поддерево. Отсюда дисциплина: верхний уровень тест-модулей — только импорты чистого кода, а всё с побочными эффектами уезжает в фикстуру, где провал ограничен тестами, которые её запросили. При подозрениях pytest --collect-only -q показывает, что будет запущено: на репозитории в 5 000 тестов один сбор занимает несколько секунд — это же ваш нижний предел для pytest -k one_test.
Балласт unittest, от которого можно избавиться: pytest запускает обычные функции, так что церемония class TestX(unittest.TestCase) и self.assertEqual ничего не добавляет — и стоит совместимости: методы TestCase не могут принимать фикстуры параметрами, а @pytest.mark.parametrize на них не работает. Группировка, когда нужна, — голый класс с префиксом Test без базового класса.
raises, warns и маркеры без фольклора
import pytest
def test_rejects_negative():
with pytest.raises(ValueError, match=r"negative amount: -\d+"):
charge(-500) # держите блок в ОДНУ строку — строку под тестом
@pytest.mark.xfail(reason="округление полуцента, баг #4231", strict=True)
def test_half_cent_rounding():
assert price_total([("X", 1, 0.005)]) == "0.01"pytest.raises(..., match=...) выполняет re.search по строковому представлению исключения — это регулярка, экранируйте скобки. Классическая самострельная рана — широкий блок with: внутри три строки подготовки, одна из которых поднимает то же исключение по другой причине, и тест проходит, пока тестируемый код сломан. Внутри держите ровно падающий вызов. pytest.warns устроен так же для предупреждений. Маркеры честно: skip документирует факт («на этой платформе нет Postgres»), skipif делает его условным, а xfail документирует известный баг, продолжая запускать тест. Флаг, отделяющий дисциплинированные сьюты от гниющих, — strict=True: xfail-тест, неожиданно прошедший (XPASS), валит сьют, и починенный баг всплывает сразу, а не прячется год за протухшим маркером. Кастомные маркеры вроде @pytest.mark.slow обязаны быть зарегистрированы в [tool.pytest.ini_options] markers, иначе каждое использование сыплет PytestUnknownMarkWarning, — а --strict-markers поднимает неизвестные маркеры до ошибок сбора: единственное, что стоит между вами и @pytest.mark.slwo, молча не выбирающим ничего.
Петля отладки и что на самом деле видит CI
Петля, делающая сьют в 2 000 тестов пригодным для жизни: полный прогон краснеет, дальше pytest --lf -x — только последние упавшие, остановка на первом провале — до зелёного, затем один полный прогон для подтверждения. --ff запускает упавшие первыми, не отбрасывая остальное; -k "payments and not slow" выбирает по выражению над именами; -x — флаг нетерпения. Состояние для --lf живёт в .pytest_cache. CI же видит только код выхода, и таблица короткая: 0 — всё прошло, 1 — есть упавшие, 2 — прервано, 3 — внутренняя ошибка, 4 — ошибка использования, 5 — ничего не собрано. Код 5 — ловушка: переименуйте директорию или ужесточите фильтр -m, и стадия с нулём тестов краснеет — поведение корректное, понедельник испорчен. Команды, «чинящие» это через pytest -m smoke || true, замаскировали код 1 вместе с кодом 5: упавшие smoke-тесты теперь проходят CI. Честный фикс обрабатывает 5 явно и позволяет 1 валить джобу.
Стадия CI запускает `pytest -m smoke || true`, чтобы необязательный smoke-сьют не блокировал деплой, когда ничего не выбрано. Что это купило на самом деле?
- 01Проследите, что происходит между запуском pytest и первым выполненным тестом — и где синглтон уровня модуля убивает сьют.
- 02Сформулируйте границу переписывания assert, правила гигиены маркеров и контракт кодов выхода для CI.
pytest построен на одном структурном решении: сбор импортирует ваш код. Хук импорта, благодаря которому голый assert даёт богатый вывод при падении — AST-переписывание каждого собираемого тест-модуля, conftest и плагина в самоописывающий байткод, — это та же машинерия, что делает from app.db import engine на уровне модуля смертельным: побочные эффекты времени импорта выполняются при сборе, и одно исключение там помечает весь сьют ошибками до выполнения единственного теста. У переписывания есть граница, которую стоит выучить — общие хелперы продолжают поднимать исключения, но голые, пока register_assert_rewrite не введёт их внутрь, — и грабли в -O, удаляющем assert оптом. Вокруг этого ядра — идиомы, держащие большие сьюты честными: raises(match=) с однострочным блоком, чтобы пройти могло только правильное исключение из правильной строки; xfail(strict=True), чтобы починенный баг падал громко, а не гнил за маркером; зарегистрированные маркеры плюс --strict-markers, чтобы опечатка не отключала тесты молча; и петля --lf -x для пути из красного в зелёное. CI не видит этих нюансов — только код выхода, где 1 значит провалы, а 5 значит, что фильтр не нашёл ничего; различайте их и не давайте || true сплющить их в успех. Теперь, когда стадия CI зеленеет после переименования, которое должно было сломать тесты — проверь, не глотает ли || true код 5; а когда падения сообщают голый AssertionError без значений — проверь, попадает ли хелпер-модуль в границу переписывания.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.