Вопрос проверяет умение тестировать ошибочные сценарии, корректно проверять исключения и формировать надёжные проверки поведения кода.
В pytest для ожидания исключений используют pytest.raises. Внутрь контекста помещают код, который должен упасть. Дополнительно можно проверить тип исключения и текст сообщения. Это делает негативные тесты точными и защищает от “случайных” падений по другой причине.
Негативный тест — тест, который проверяет, что система корректно обрабатывает неверный ввод или ошибочное состояние (обычно через исключение или возвращаемую ошибку).
Перед практикой важно понимать: исключение — часть контракта функции. Если функция должна “падать” на неверном вводе, тесты должны зафиксировать:
какой именно тип ошибки возникает
в каком месте и при каких условиях
что ошибка не скрывается и не превращается в “тихий” неправильный результат
pytest.raisesЭто минимальный корректный вариант.
import pytest
def parse_age(value: str) -> int:
age = int(value)
if age < 0:
raise ValueError("age must be non-negative")
return age
def test_parse_age_raises_on_text():
with pytest.raises(ValueError):
parse_age("abc")
Это полезно, когда важен смысл ошибки (и вы хотите защититься от “падения не там”).
def test_parse_age_raises_on_negative():
with pytest.raises(ValueError, match="non-negative"):
parse_age("-1")
Важно: match — это регулярное выражение, поэтому лучше проверять устойчивую часть текста.
Иногда нужно проверить поля исключения или точное сообщение.
def test_parse_age_error_details():
with pytest.raises(ValueError) as exc_info:
parse_age("-1")
assert "non-negative" in str(exc_info.value)
Одна из самых частых ошибок — слишком широкий блок raises, куда попадает лишний код.
Плохо:
with pytest.raises(ValueError):
obj = make_obj() # может упасть само по себе
result = obj.run() # а вы думаете, что падает тут
Лучше:
obj = make_obj()
with pytest.raises(ValueError):
obj.run()
Если функция должна падать только на неверном вводе — добавьте позитивный тест рядом.
def test_parse_age_ok():
assert parse_age("10") == 10
Если вы проверяете внутренние детали (точный текст исключения, класс в глубине), тесты станут хрупкими. Обычно достаточно:
тип исключения
стабильный фрагмент сообщения
код/атрибут ошибки, если он есть (лучше, чем текст)
Это резко повышает покрытие и снижает дублирование.
import pytest
@pytest.mark.parametrize("value", ["", "abc", "10.5"])
def test_parse_age_bad_inputs(value):
with pytest.raises(ValueError):
parse_age(value)
async-функцииКонтекст тот же, но нужно await.
import pytest
async def fetch_user(user_id: int) -> dict:
if user_id <= 0:
raise ValueError("invalid user_id")
return {"id": user_id}
@pytest.mark.asyncio
async def test_fetch_user_raises():
with pytest.raises(ValueError, match="invalid"):
await fetch_user(0)
Исключение может возникать при итерации.
def gen():
yield 1
raise RuntimeError("boom")
def test_generator_raises_on_next():
g = gen()
assert next(g) == 1
with pytest.raises(RuntimeError, match="boom"):
next(g)
Иногда система ловит исключение и возвращает результат “с ошибкой”. Тогда негативный тест должен проверять именно этот контракт:
возвращаемую структуру ошибки
код ошибки
флаг успеха
логирование (если это часть требований)
Пример идеи (без привязки к фреймворку):
def do_action():
try:
...
except ValueError:
return {"ok": False, "error": "bad_input"}
Тест:
def test_do_action_returns_error():
result = do_action()
assert result["ok"] is False
assert result["error"] == "bad_input"
Тест “проходит” даже если код упал по другой причине (слишком широкий raises)
Проверяется текст ошибки целиком (хрупко)
Нет позитивного теста рядом, и контракт не очевиден
Проверяется неправильный тип исключения (слишком общий Exception)
Тесты не различают ошибки валидации и ошибки инфраструктуры
Негативные тесты в pytest корректнее всего писать через pytest.raises, ограничивая блок только кодом, который должен упасть, и проверяя тип исключения (и при необходимости устойчивую часть сообщения). Такой подход делает тесты точными, устойчивыми и полезными при регрессиях.