Тестирование исключений

Как проверять, что код корректно поднимает исключения в нужных ситуациях.

assertRaises — assert-метод, который проходит, только если внутри блока действительно возникло ожидаемое исключение.

Зачем тестировать исключения

Хорошая функция не только возвращает правильный результат на верных входах, но и понятно отказывает на неверных. Если на деление на ноль или на отрицательный возраст функция должна поднять ошибку — это поведение тоже нужно проверять. Иначе кто-то «починит» и тихо вернёт мусор вместо исключения.

assertRaises как контекстный менеджер

Самая удобная форма — with self.assertRaises(...). Внутри блока вызываем код, который должен упасть. Если он упал нужным исключением — тест проходит; если не упал или упал другим — падает сам тест.

import unittest

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("делить на ноль нельзя")
    return a / b

class TestDivide(unittest.TestCase):
    def test_normal(self):
        self.assertEqual(divide(10, 2), 5)

    def test_zero_raises(self):
        # Ожидаем, что внутри блока поднимется ZeroDivisionError
        with self.assertRaises(ZeroDivisionError):
            divide(1, 0)

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_normal (__main__.TestDivide.test_normal) ... ok
test_zero_raises (__main__.TestDivide.test_zero_raises) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Проверяем сообщение: assertRaisesRegex

Иногда важно убедиться не только в типе исключения, но и в тексте сообщения. assertRaisesRegex дополнительно проверяет, что сообщение совпадает с регулярным выражением (достаточно вхождения подстроки).

import unittest

def set_age(value):
    if value < 0:
        raise ValueError("возраст не может быть отрицательным")
    return value

class TestAge(unittest.TestCase):
    def test_negative_message(self):
        with self.assertRaisesRegex(ValueError, "отрицательным"):
            set_age(-5)

    def test_valid(self):
        self.assertEqual(set_age(30), 30)

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_negative_message (__main__.TestAge.test_negative_message) ... ok
test_valid (__main__.TestAge.test_valid) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Доступ к самому исключению

Контекст можно сохранить в переменную через as и затем разобрать пойманное исключение — например, проверить его атрибуты:

import unittest

class ValidationError(Exception):
    def __init__(self, field, message):
        super().__init__(message)
        self.field = field

def validate_email(email):
    if "@" not in email:
        raise ValidationError("email", "нет символа @")

class TestValidation(unittest.TestCase):
    def test_error_has_field(self):
        with self.assertRaises(ValidationError) as ctx:
            validate_email("bad-email")
        # Разбираем пойманное исключение
        self.assertEqual(ctx.exception.field, "email")

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_error_has_field (__main__.TestValidation.test_error_has_field) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Через ctx.exception мы достаём сам объект исключения и проверяем, что в нём указано правильное поле.

Частая ошибка

Нельзя вызвать функцию внутри assertRaises заранее. Неправильно: self.assertRaises(ValueError, set_age(-5)) — здесь set_age(-5) выполнится до передачи в метод и исключение вылетит мимо проверки. Правильно — либо контекст with, либо передавать функцию и аргументы по отдельности: self.assertRaises(ValueError, set_age, -5).

Проверяйте и положительный случай тоже

Тест на исключение почти всегда ходит в паре с тестом на нормальную работу. Недостаточно проверить, что функция падает на плохом входе, — нужно убедиться, что на хорошем входе она возвращает правильный результат. Иначе можно случайно написать функцию, которая всегда кидает исключение, и тест на исключение будет зелёным, создавая ложное ощущение корректности. Поэтому для валидатора пишут минимум два теста: «валидный вход — проходит» и «невалидный вход — поднимает ошибку».

Какое исключение ожидать

Указывайте в assertRaises конкретный тип ошибки, а не общий Exception. Если ждать просто Exception, тест пройдёт на любой ошибке — даже на случайной опечатке вроде NameError, которая к проверяемому поведению отношения не имеет. Точный тип (ValueError, KeyError, ваш собственный класс) гарантирует, что код упал именно по той причине, которую вы проверяете, а не из-за постороннего бага.

Итог

  • with self.assertRaises(Err): проверяет, что код поднял нужное исключение.
  • assertRaisesRegex дополнительно проверяет текст сообщения.
  • as ctx даёт доступ к объекту исключения через ctx.exception.
  • Внутри with вызывайте код напрямую — не передавайте уже вычисленный результат.
Проверьте себя
1. Как проверить, что код поднимает исключение?
Atry/except в каждом тесте вручную
Bwith self.assertRaises(ErrorType): ...
CassertEqual с текстом ошибки
DНикак
2. Что дополнительно проверяет assertRaisesRegex?
AСкорость
BЧто сообщение исключения совпадает с регулярным выражением
CТип возвращаемого значения
DКоличество вызовов
3. Как получить доступ к самому объекту исключения?
AЧерез self.exception
BЧерез as ctx и затем ctx.exception
CЧерез return
DНевозможно
Поддержать проект