Привет, Хабр!

Сегодня говорим о 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

Используйте узкие блоки with — только вызов функции

2

Проверяйте не только тип, но и текст ошибки через match

3

Не ловите Exception — это может скрыть реальные ошибки

4

Не используйте match как подстроку — это полноценное регулярное выражение

5

Обрабатывайте исключение через as exc_info, если нужно проверить поля

6

Делайте отдельные тесты на разные исключения — это повышает читаемость

7

Не используйте pytest.raises, если вызов происходит по условию — применяйте pytest.skip()

Используйте pytest.raises грамотно — и ваши тесты будут не просто зелёными, но надёжными.


Чтобы повысить уровень тестирования и исключить ошибки на всех этапах разработки, рекомендую вам обратить внимание на несколько практических уроков. Они помогут улучшить навыки работы с исключениями, тестированием и интеграцией систем:

Комментарии (2)


  1. evgenyk
    17.05.2025 13:58

    ИМХО, в Питоне неправильно называть "исключение ошибкой". Это совершенно законный способ изменить ход течения программы.


    1. bayan79
      17.05.2025 13:58

      раз в Питоне любая ошибка - это исключение, которое можно обработать, то не являются ли термены "ошибка" и "исключение" в контексте питона равнозначными? иначе в чем практическая польза отделять одно от другого?