Привет, Хабр!
Сегодня говорим о 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
грамотно — и ваши тесты будут не просто зелёными, но надёжными.
Чтобы повысить уровень тестирования и исключить ошибки на всех этапах разработки, рекомендую вам обратить внимание на несколько практических уроков. Они помогут улучшить навыки работы с исключениями, тестированием и интеграцией систем:
evgenyk
ИМХО, в Питоне неправильно называть "исключение ошибкой". Это совершенно законный способ изменить ход течения программы.
bayan79
раз в Питоне любая ошибка - это исключение, которое можно обработать, то не являются ли термены "ошибка" и "исключение" в контексте питона равнозначными? иначе в чем практическая польза отделять одно от другого?