Тестирование исключений в pytest: как проверить ошибки в коде
Для кого эта статья:
- Разработчики и тестировщики, работающие с Python и автоматизированным тестированием
- Специалисты, желающие улучшить свои навыки в области тестирования исключений
Люди, заинтересованные в повышении надежности и качества программного обеспечения через эффективные тесты
Тестирование исключений — один из самых недооцененных аспектов автоматизации тестирования. Слишком часто разработчики проверяют только "счастливые пути" выполнения кода, игнорируя случаи, когда должны возникать ошибки. Правильная проверка исключений в pytest — это тонкое искусство, требующее понимания как самого фреймворка, так и механизмов обработки исключений в Python. 🐍 В этой статье я детально разберу методы проверки исключений, которые помогут превратить ваши тесты из хрупких заготовок в надежный щит вашего кода.
Если вы стремитесь к совершенству в Python-разработке и хотите систематизировать свои знания тестирования, обратите внимание на Обучение Python-разработке от Skypro. Курс включает продвинутые модули по автоматизированному тестированию, где вы не только освоите pytest на практике, но и научитесь писать высококачественные тесты под руководством опытных разработчиков, решающих реальные задачи в индустрии.
Основы тестирования исключений в pytest
Тестирование исключений — это проверка того, что код корректно выбрасывает ошибки в ожидаемых ситуациях. Это критически важно для обеспечения надежности программного обеспечения, особенно когда речь идет о валидации входных данных, обработке ресурсов или сетевых операциях.
В pytest есть специальный инструмент — pytest.raises(), который превращает потенциально сложную задачу проверки исключений в элегантный и читаемый код. Этот инструмент позволяет проверить, что определенный код выбрасывает ожидаемое исключение, а также предоставляет доступ к самому объекту исключения для дальнейшего анализа.
Прежде чем погрузиться в детали, давайте рассмотрим ключевые компоненты процесса тестирования исключений:
- Тестируемый код: функция или метод, который должен выбросить исключение при определенных условиях
- Ожидаемый тип исключения: конкретный класс исключения (например,
ValueError,TypeError) - Условия: входные данные или состояние, которые должны привести к исключению
- Сообщение исключения (опционально): текст, который должен присутствовать в выброшенном исключении
Базовая структура теста на исключение выглядит примерно так:
def test_exception_is_raised():
with pytest.raises(ExpectedException):
# Код, который должен выбросить исключение
function_under_test()
Такой подход позволяет pytest проверить, что код внутри блока with действительно выбрасывает исключение указанного типа. Если исключение не возникает или выбрасывается исключение другого типа — тест провалится.
Михаил Соколов, Senior QA Automation Engineer
Помню свой первый опыт с тестированием API на Python. Я написал десятки тестов, проверяющих "счастливые пути" — корректные запросы, ожидаемые ответы. Код выглядел прекрасно, покрытие казалось достаточным. Но когда мы выпустили продукт в продакшн, начали сыпаться баги из пограничных случаев — неправильные форматы данных, недоступные ресурсы, таймауты.
Именно тогда я осознал важность тестирования исключений. Пришлось срочно дописывать тесты, проверяющие как система обрабатывает ошибки. Использование pytest.raises() кардинально изменило мой подход к тестированию. Теперь каждый раз, когда я пишу тест на функциональность, я сразу добавляю серию тестов на различные сценарии ошибок. Это сэкономило команде десятки часов отладки и повысило надежность системы.
Существует несколько различных подходов к тестированию исключений в pytest, каждый с собственными преимуществами и недостатками:
| Метод | Описание | Преимущества | Недостатки |
|---|---|---|---|
Контекстный менеджер with pytest.raises() | Проверяет, что код внутри блока with выбрасывает указанное исключение | Читаемость, лаконичность, возможность проверить атрибуты исключения | Ограниченная гибкость при необходимости проверить несколько вызовов |
Декоратор @pytest.mark.xfail | Помечает тест как ожидаемо падающий из-за исключения | Простота применения, подходит для тестов на регрессию | Меньше контроля, невозможность проверить атрибуты исключения |
Встроенный try-except | Обрабатывает исключение напрямую с использованием конструкции Python | Максимальная гибкость, доступ к любым деталям исключения | Избыточность кода, потенциальные ложно-положительные результаты |
В большинстве случаев контекстный менеджер with pytest.raises() является оптимальным выбором благодаря сочетанию лаконичности и гибкости.

Использование pytest.raises() для проверки исключений
Функция pytest.raises() — это основной инструмент для тестирования исключений в pytest. Она позволяет проверить, выбрасывает ли определенный блок кода ожидаемое исключение, и, при необходимости, проверить его атрибуты.
Базовый синтаксис выглядит так:
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
В этом примере тест пройдет успешно, потому что деление на ноль действительно выбрасывает ZeroDivisionError.
Одно из главных преимуществ pytest.raises() — возможность проверить детали исключения. Вы можете получить доступ к объекту исключения следующим образом:
def test_value_error_with_message():
with pytest.raises(ValueError) as excinfo:
raise ValueError("Invalid value")
assert "Invalid value" in str(excinfo.value)
Здесь excinfo — это объект контекстного менеджера, который предоставляет доступ к выброшенному исключению через атрибут value.
Для более сложных случаев pytest.raises() предлагает несколько полезных параметров:
match: проверяет, соответствует ли сообщение исключения регулярному выражениюmessage: настраиваемое сообщение, которое будет показано, если исключение не будет выброшено
Рассмотрим более подробный пример:
def validate_age(age):
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 120:
raise ValueError("Age is too high, maximum is 120")
return True
def test_validate_age_with_match():
# Тест на тип данных
with pytest.raises(TypeError, match=r".*integer"):
validate_age("twenty")
# Тест на отрицательное значение
with pytest.raises(ValueError, match=r".*negative"):
validate_age(-5)
# Тест на слишком большое значение
with pytest.raises(ValueError, match=r".*too high"):
validate_age(150)
Параметр match принимает строку регулярного выражения, что дает гибкость при проверке сообщений исключений. В этом примере r".*integer" будет соответствовать любой строке, содержащей слово "integer".
При работе с pytest.raises() можно также использовать альтернативный синтаксис — вызов функции вне блока with:
def test_exception_alternative_syntax():
pytest.raises(ValueError, validate_age, -5)
Этот синтаксис более компактен, но менее гибок, так как не дает прямого доступа к объекту исключения и не позволяет выполнять сложные проверки внутри контекстного менеджера.
Сравним различные варианты использования pytest.raises():
| Синтаксис | Пример | Когда использовать |
|---|---|---|
| Базовый с контекстным менеджером | with pytest.raises(ValueError): func() | Большинство случаев, особенно с простыми проверками |
| С захватом исключения | with pytest.raises(ValueError) as excinfo: func() | Когда нужно проверить атрибуты исключения |
| С параметром match | with pytest.raises(ValueError, match=r"pattern"): func() | Когда сообщение исключения имеет значение |
| Функциональный синтаксис | pytest.raises(ValueError, func, arg1, arg2) | Для простых проверок с фиксированными аргументами |
Выбор синтаксиса зависит от конкретной ситуации и предпочтений в стиле кода. Однако в сложных случаях контекстный менеджер с захватом исключения предоставляет наибольшую гибкость.
Контекстный менеджер with для assert исключений
Контекстный менеджер with в сочетании с pytest.raises() является наиболее элегантным и мощным способом тестирования исключений в pytest. Эта конструкция не только проверяет факт возникновения исключения, но и предоставляет контекст для проведения дополнительных проверок.
Основной шаблон использования выглядит так:
with pytest.raises(ExpectedException) as excinfo:
# Код, который должен выбросить исключение
# Дополнительные проверки объекта исключения
assert excinfo.value.attribute == expected_value
Преимущество этого подхода в том, что весь код внутри блока with рассматривается как единое целое. Если исключение ожидаемого типа возникает на любом этапе выполнения этого кода, тест считается успешным. Если исключение не возникает или возникает исключение другого типа, тест провалится.
Рассмотрим более сложный пример с многоуровневыми вызовами функций:
def process_data(data):
return transform_data(validate_data(data))
def validate_data(data):
if not data:
raise ValueError("Data cannot be empty")
return data
def transform_data(data):
# Какая-то трансформация данных
return data
def test_process_data_with_empty_input():
with pytest.raises(ValueError) as excinfo:
process_data({})
assert "empty" in str(excinfo.value)
В этом примере мы тестируем не просто функцию validate_data(), а целую цепочку вызовов функций, начинающуюся с process_data(). Контекстный менеджер with позволяет нам элегантно проверить, что исключение возникает где-то в этой цепочке.
Анна Петрова, DevOps-инженер
Год назад я работала над созданием автоматизации для развертывания инфраструктуры. Мы использовали Python для написания скриптов, взаимодействующих с AWS API. Наша команда столкнулась с проблемой: CI/CD пайплайны периодически падали из-за неожиданных ответов от API.
Мы имели базовые тесты для позитивных сценариев, но полностью игнорировали обработку ошибок. После очередного ночного инцидента я решила полностью переписать нашу тестовую стратегию, фокусируясь на проверке корректной обработки исключений.
Ключевым инструментом стал контекстный менеджер pytest.raises(). Мы создали набор тестов, моделирующих различные сценарии ошибок: таймауты, отказы сервисов, недостаточные права доступа. Для каждого сценария мы проверяли не только сам факт возникновения исключения, но и корректность его сообщения и атрибутов.
Этот подход полностью изменил надежность нашей системы. За последующие полгода у нас не было ни одного инцидента, связанного с неправильной обработкой ошибок API. Более того, время на отладку новых функций сократилось на 40%, так как тесты стали выявлять потенциальные проблемы еще на этапе разработки.
Важно понимать, что pytest.raises() проверяет только первое исключение, которое возникает в блоке. Если вам нужно проверить несколько исключений, потребуется несколько отдельных тестов или вложенных блоков with.
Рассмотрим примеры типичных сценариев использования контекстного менеджера:
- Проверка исключения при вызове функции с некорректными аргументами:
with pytest.raises(ValueError):
calculate_square_root(-5)
- Проверка исключения при доступе к несуществующему ресурсу:
with pytest.raises(FileNotFoundError):
with open("non_existent_file.txt", "r") as f:
content = f.read()
- Проверка исключения при работе с API:
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
response = requests.get("https://api.example.com/resource")
response.raise_for_status()
assert excinfo.value.response.status_code == 404
При использовании контекстного менеджера with следует помнить о некоторых нюансах:
- Весь код внутри блока
withдолжен быть минимально необходимым для проверки исключения. Избыточный код может скрыть проблемы или привести к возникновениюunexpected исключений. - Если вам нужно проверить несколько шагов, рассмотрите возможность использования вспомогательной функции, которая инкапсулирует эти шаги.
- Помните, что
pytest.raises()считает тест успешным, если исключение возникает в любой точке блокаwith. Это может привести к ложно-положительным результатам, если исключение возникает не там, где вы ожидаете.
Проверка атрибутов и сообщений исключений
Тестирование факта возникновения исключения — это только половина дела. Полноценный тест должен также проверять содержимое исключения, включая его сообщение и другие атрибуты. Это особенно важно, когда исключения используются для передачи информации о конкретной проблеме. 🔍
Существует несколько способов проверки деталей исключения в pytest:
1. Проверка сообщения исключения через объект excinfo
def test_exception_message():
with pytest.raises(ValueError) as excinfo:
raise ValueError("Critical error occurred")
assert "Critical error" in str(excinfo.value)
В этом примере мы используем атрибут value объекта excinfo, который содержит сам объект исключения. Преобразуя его в строку с помощью str(), мы получаем сообщение исключения.
2. Использование параметра match
def test_exception_with_match():
with pytest.raises(ValueError, match=r"Critical.*occurred"):
raise ValueError("Critical error occurred")
Параметр match принимает регулярное выражение и автоматически проверяет, соответствует ли сообщение исключения этому выражению. Это более лаконичный способ, особенно когда вам не нужно проводить дополнительные проверки объекта исключения.
3. Проверка специфичных атрибутов исключения
Многие исключения имеют дополнительные атрибуты помимо сообщения. Например, исключения, связанные с HTTP-запросами, часто содержат статус-код, заголовки и другую информацию.
def test_http_exception_attributes():
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
response = requests.Response()
response.status_code = 404
response._content = b'{"error": "Resource not found"}'
response.url = "https://api.example.com/resource"
response.raise_for_status()
exception = excinfo.value
assert exception.response.status_code == 404
assert "Resource not found" in exception.response.text
Для пользовательских исключений проверки могут быть еще более специфичными:
class CustomError(Exception):
def __init__(self, message, error_code, details=None):
super().__init__(message)
self.error_code = error_code
self.details = details or {}
def test_custom_exception_attributes():
with pytest.raises(CustomError) as excinfo:
raise CustomError(
"Authentication failed",
error_code="AUTH_001",
details={"user_id": 12345, "reason": "Token expired"}
)
exception = excinfo.value
assert exception.error_code == "AUTH_001"
assert exception.details["reason"] == "Token expired"
assert "Authentication" in str(exception)
Сравним различные методы проверки исключений:
| Метод | Преимущества | Недостатки | Пример использования |
|---|---|---|---|
Проверка через str(excinfo.value) | Полный доступ к сообщению, гибкие проверки | Многословность, зависимость от формата строкового представления | Сложные проверки содержимого сообщения |
Параметр match | Лаконичность, встроенная поддержка регулярных выражений | Ограниченная гибкость, только проверка сообщения | Простые проверки формата сообщения |
| Проверка специфичных атрибутов | Точность, доступ к внутренней структуре исключения | Зависимость от конкретной реализации исключения | Проверка технических деталей исключения |
При выборе метода проверки атрибутов исключения рекомендуется руководствоваться следующими принципами:
- Используйте параметр
matchдля простых проверок сообщений, особенно когда важна только часть сообщения или его общий формат. - Используйте прямой доступ к атрибутам исключения через
excinfo.value, когда вам нужно проверить специфические детали исключения. - Стремитесь к балансу между конкретностью проверок и устойчивостью тестов к изменениям в форматах сообщений.
Помните, что слишком конкретные проверки (например, точное соответствие сообщения исключения) могут сделать ваши тесты хрупкими и требующими частого обновления при изменениях в коде. С другой стороны, слишком общие проверки могут пропустить важные дефекты.
Распространённые ошибки при тестировании исключений
Несмотря на кажущуюся простоту, тестирование исключений в pytest таит в себе множество подводных камней. Разработчики и тестировщики регулярно сталкиваются с определенными ошибками, которые могут привести к ненадежным тестам или ложным срабатываниям. Давайте рассмотрим наиболее распространенные из них. 🚨
1. Пропуск контекстного менеджера with
Одна из самых распространенных ошибок — попытка использовать pytest.raises() без контекстного менеджера with:
# Неправильно!
def test_division_by_zero_incorrect():
pytest.raises(ZeroDivisionError)
1 / 0 # Это исключение не будет перехвачено pytest.raises()
# Правильно
def test_division_by_zero_correct():
with pytest.raises(ZeroDivisionError):
1 / 0
В первом примере pytest.raises() просто создает объект, но не выполняет никакой проверки. Исключение ZeroDivisionError будет выброшено как обычно и приведет к провалу теста.
2. Проверка слишком широкого типа исключения
Иногда разработчики указывают слишком общий тип исключения, что может скрыть реальные проблемы:
# Слишком общий тип исключения
def test_with_too_general_exception():
with pytest.raises(Exception): # Перехватит любое исключение!
process_data({"invalid": "data"})
# Более конкретно и правильно
def test_with_specific_exception():
with pytest.raises(ValueError):
process_data({"invalid": "data"})
В первом примере тест пройдет успешно, даже если функция выбросит неожиданное исключение (например, TypeError или KeyError), что может скрыть реальные проблемы в коде.
3. Ошибки при проверке сообщений исключений
Частой ошибкой является неправильное использование параметра match или прямой проверки сообщения исключения:
# Неправильное использование match
def test_invalid_match_usage():
with pytest.raises(ValueError, match="Exact message"):
raise ValueError("Different message") # Тест провалится
# Слишком жесткая проверка сообщения
def test_too_strict_message_check():
with pytest.raises(ValueError) as excinfo:
raise ValueError("Error in function process_data")
assert str(excinfo.value) == "Error in function process_data" # Хрупкий тест
В первом примере параметр match ищет точное соответствие, хотя часто лучше использовать регулярные выражения для более гибкой проверки. Во втором примере проверка слишком конкретна и может легко сломаться при небольших изменениях в сообщении исключения.
4. Игнорирование возможных исключений в setup-коде
Иногда исключение возникает не там, где вы ожидаете:
# Проблема с неожиданным исключением в setup
def test_api_error_handling():
with pytest.raises(ApiError):
client = ApiClient("invalid-url") # Может выбросить ConnectionError
client.get_data() # Этот код может не выполниться
В этом примере тест может пройти успешно, если ApiClient выбросит ConnectionError, хотя на самом деле мы хотели проверить поведение метода get_data().
5. Неправильное использование assert после pytest.raises
# Неправильная структура теста
def test_incorrect_structure():
with pytest.raises(ValueError):
value = process_data({})
assert value == expected_result # Эта строка никогда не выполнится, если исключение произошло
Этот код содержит логическую ошибку: если process_data() выбрасывает исключение, переменная value не будет создана, и следующий assert вызовет ошибку.
6. Избыточные тесты на исключения
Иногда разработчики создают избыточные тесты, проверяющие встроенное поведение Python или стандартных библиотек:
# Избыточный тест
def test_redundant():
with pytest.raises(TypeError):
"string" + 5 # Встроенное поведение Python, не требует отдельного теста
Такие тесты не добавляют ценности, так как проверяют не ваш код, а стандартное поведение языка или библиотек.
Чтобы избежать описанных выше ошибок, следуйте этим рекомендациям:
- Всегда используйте
pytest.raises()с контекстным менеджеромwith. - Указывайте максимально конкретный тип исключения.
- Используйте регулярные выражения с параметром
matchдля гибкой проверки сообщений. - Минимизируйте код внутри блока
with pytest.raises()до необходимого минимума. - Если вам нужно проверить значение после потенциального исключения, используйте отдельные тесты для позитивных и негативных сценариев.
- Тестируйте только поведение вашего кода, а не стандартные механизмы Python или библиотек.
Помните, что цель тестирования исключений — не просто проверить, что код выбрасывает ошибки, а убедиться, что он делает это в нужных ситуациях и с правильной информацией, которая поможет диагностировать проблему.
Тестирование исключений — это не просто формальность, а важный аспект обеспечения качества кода. Правильная проверка исключений с использованием pytest.raises() помогает гарантировать, что ваш код не только работает в идеальных условиях, но и элегантно справляется с ошибками. Мастерство в этой области приходит с практикой — начните с простых случаев и постепенно переходите к более сложным сценариям. Помните: код, который корректно обрабатывает исключительные ситуации, часто оказывается решающим фактором между надежным приложением и системой, которая разваливается при первом же неожиданном входе.