Тестирование исключений
Как проверять, что код корректно поднимает исключения в нужных ситуациях.
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вызывайте код напрямую — не передавайте уже вычисленный результат.