Привет, Хабр!
Сегодня говорим о pytest.raises. Не о его наличии в экосистеме — это известно каждому, кто хоть раз писал тесты. Говорим о правильном использовании. Потому что между «тест проходит» и «тест действительно что‑то проверяет» — пропасть.
Контекст и ожидание: что делает pytest.raises?
Тест — это не просто проверка результата. Это формулировка ожидания. Мы говорим системе: вот этот участок кода, и вот что мы считаем его корректным поведением. И если функция должна выбросить исключение — мы обязаны это зафиксировать в тесте как норму, а не как сбой.
pytest.raises является той конструкцией, которой мы говорим: вот сейчас должно случиться исключение — и это хорошо. Пример:
with pytest.raises(MyError): print(broken_object) # здесь ломается __str__ perform_action()
На языке ожиданий это звучит так: «Я заранее знаю, что func() завершится аварийно, и считаю это нормальным исходом. Более того — если этого не произойдёт, значит, код работает неправильно».
И это не ловушка, не try/except, не защита от фатала. Это — осознанная декларация ошибки как части правильного поведения.
Что делает pytest.raises? В момент входа в with он ставит ловушку на все исключения в теле блока. Если в процессе исполнения возникает исключение нужного типа — тест проходит. Если исключения нет, или оно другого типа — тест падает. Но есть нюанс: pytest при этом не перехватывает исключение молча, он сохраняет его в специальный объект, доступный для анализа.
Т.е pytest.raises — это не просто способ словить ошибку. Это формальный способ описать, что ошибка — ожидаема и контролируема, и при этом — доступ к деталям этой ошибки: текст, тип, поля, stack trace.
Первый миф: исключение будет поймано — и это всегда хорошо?
Да, pytest.raises действительно ловит исключение, если оно случилось внутри блока with. Это и есть его назначение. Но важно понять что именно он ловит.
Он ловит любое исключение указанного типа, которое произойдёт внутри блока. А внутри может быть не только вызов вашей функции, но и любой другой побочный эффект: логирование, печать, f‑строка, даже попытка отрисовать объект в консоли.
Посмотрим на пример:
def perform_action(): pass # ничего не делает, никаких исключений def test_broken_str(): class Broken: def __str__(self): raise MyError("String rendering failed") broken_obj = Broken() with pytest.raises(MyError): print(f"About to act on: {broken_obj}") # ← исключение тут perform_action()
На первый взгляд, вы тестируете, что perform_action() вызывает MyError. Но в реальности исключение происходит на этапе форматирования строки. Тест пройдёт — хотя ваша функция ни при чём.
Это называется ложноположительный результат: pytest доволен, но ошибка — в другом месте. И в продакшене это может означать, что вы проглотили баг и просто его не заметили.
Всегда изолируйте вызов, от которого вы реально ожидаете исключение:
# правильный вариант broken_obj = Broken() print("About to act on: object prepared") with pytest.raises(MyError): perform_action()
Идея простая: в блоке with должен быть только тот вызов, исключение из которого вы считаете допустимым. Всё остальное — за пределами with.
Второй миф: match — это подстрока. На деле — регулярное выражение
Многие используют match, думая, что он проверяет: «содержится ли строка А в сообщении ошибки?» Увы — нет. match передаётся в re.search(), а значит, это полноценное регулярное выражение.
Простой пример:
with pytest.raises(ValueError, match="must be positive"): raise ValueError("Input must be positive")
Этот тест упадёт, потому что re.search("must be positive", "Input must be positive") вернёт None. Почему? Потому что match проверяет совпадение с шаблоном, а не подстроку.
Теперь пример, где всё ломается из‑за спецсимвола:
with pytest.raises(ValueError, match="1 + 1 = 2"): raise ValueError("1 + 1 = 2")
Тест упадёт. Потому что + в регулярке означает «один или более символов перед ним». В нашем случае — это не то, что мы хотим.
Решения
1. Использовать «сырую» строку (r"...") с экранированием:
with pytest.raises(ValueError, match=r"1 \+ 1 = 2"): ...
2. Или — безопасный путь — воспользоваться re.escape, особенно если текст ошибки получен динамически:
import re msg = "1 + 1 = 2" with pytest.raises(ValueError, match=re.escape(msg)): raise ValueError(msg)
Если используете match — относитесь к нему как к re.search(), а не in.
Проверка содержания исключения: as exc_info
Иногда само наличие исключения — не достаточно. Нужно проверить, что именно оно содержит. Это особенно важно при тестировании бизнес‑логики, кастомных исключений, сериализаторов и API.
Представим кастомную ошибку:
class ValidationError(Exception): def __init__(self, code: int, message: str): self.code = code super().__init__(message)
Если система выбрасывает такую ошибку, хочется проверить не только текст, но и поле code.
Вот так делать нельзя:
with pytest.raises(ValidationError): raise ValidationError(400, "Bad request")
Потому что вы не проверили, какая ошибка. А если кто то случайно поменяет код на 500, тест всё равно пройдёт.
Правильный способ:
with pytest.raises(ValidationError) as exc_info: raise ValidationError(400, "Bad request") assert exc_info.value.code == 400 assert str(exc_info.value) == "Bad request"
Через exc_info.value есть полный доступ к экземпляру исключения. Это важно, если внутри ошибки есть:
-
HTTP‑статус,
-
список полей с ошибками,
-
коды локализации и т. д.
Опасный соблазн: ловить Exception и BaseException
В начале — удобно. Написал:
with pytest.raises(Exception): call()
И тест проходит. Но так можно пропустить всё, что угодно. Exception включает в себя:
-
ValueError, -
TypeError, -
RuntimeError, -
AssertionError(что опасно в тестах — можно случайно поймать ошибку изassert).
Ещё хуже:
with pytest.raises(BaseException): ...
Теперь ловим даже:
-
KeyboardInterrupt(нажатие Ctrl+C), -
SystemExit(например, при вызовеsys.exit()), -
GeneratorExit.
Так делать категорически нельзя. Это уже не тестирование, а ловля всего подряд.
Всегда явно указывайте тот тип, который ожидаете. Не шире, чем надо.
Несколько исключений: можно, но осторожно
Иногда поведение функции зависит от контекста, и она может выбросить одну из нескольких ошибок. В таких случаях разрешено писать:
with pytest.raises((ValueError, TypeError)): process_input(data)
Такой код корректен: pytest проверит, что хотя бы один из типов сработал. Но у этого есть ограничения.
Если вы одновременно передаёте match, то pytest не сможет точно понять, к какому из исключений применять шаблон. И вы получите ошибку.
Альтернатива: разнесите проверки
def test_type_error(): with pytest.raises(TypeError, match="expected string"): ... def test_value_error(): with pytest.raises(ValueError, match="cannot be negative"): ...
Так тесты:
-
понятнее,
-
точнее локализуют проблему,
-
не мешают друг другу.
pytest.raises как функция
Чаще всего pytest.raises применяют в контексте:
with pytest.raises(SomeError): call()
Но можно использовать его как функцию — особенно если тестируется компактный вызов.
exc_info = pytest.raises(ValueError, lambda: int("abc")) assert "invalid literal" in str(exc_info.value)
Или с аргументами:
def parse_int(x): return int(x) exc_info = pytest.raises(ValueError, parse_int, "abc") assert "invalid" in str(exc_info.value)
Это удобно для однострочных функций, но у этого способа есть ограничения:
-
вы не можете явно контролировать, где в коде возникло исключение;
-
вы не можете использовать
asдля доступа к stack trace и context.
Поэтому в большинстве случаев контекстный менеджер — предпочтительнее. Он:
-
ограничивает зону ловли;
-
даёт читаемость;
-
даёт больше контроля.
Напоследок: чек-лист для безопасного использования pytest.raises
|
№ |
Рекомендация |
|---|---|
|
1 |
Используйте узкие блоки |
|
2 |
Проверяйте не только тип, но и текст ошибки через |
|
3 |
Не ловите |
|
4 |
Не используйте |
|
5 |
Обрабатывайте исключение через |
|
6 |
Делайте отдельные тесты на разные исключения — это повышает читаемость |
|
7 |
Не используйте |
Используйте pytest.raises грамотно — и ваши тесты будут не просто зелёными, но надёжными.
Чтобы повысить уровень тестирования и исключить ошибки на всех этапах разработки, рекомендую вам обратить внимание на несколько практических уроков. Они помогут улучшить навыки работы с исключениями, тестированием и интеграцией систем:
ссылка на оригинал статьи https://habr.com/ru/articles/901858/
Добавить комментарий