Python · Тестирование с pytest · Начальный

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

5 задач

Проверяйте, что код выбрасывает правильные исключения с помощью pytest.raises.

Тестирование того, что код падает правильно

#
Функция, выбрасывающая исключение на некорректных входных данных, ведёт себя верно -- выброс является частью её контракта. Тестирование такого поведения столь же важно, как тестирование основного сценария. **pytest.raises -- базовый паттерн:** ```python import pytest def divide(a, b): return a / b def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): divide(10, 0) ``` `pytest.raises` -- это контекстный менеджер. Внутри блока `with` вызывается код, который *должен* выбросить исключение. Три возможных исхода: | Что происходит | Результат теста | |---|---| | Ожидаемое исключение выброшено | PASSED | | Исключение не выброшено | FAILED -- `DID NOT RAISE` | | Выброшено другое исключение | FAILED -- непредвиденное исключение всплывает | Третий случай важен: если `divide(10, 0)` выбросит `ValueError` вместо `ZeroDivisionError`, тест упадёт с неожиданным `ValueError` в выводе. Нельзя случайно пройти с неверным типом исключения. **Проверка сообщения исключения через `match`:** Параметр `match` принимает **regex**-шаблон, сопоставляемый с `str(exception)`. Не нужно совпадать со всем сообщением -- достаточно характерной подстроки: ```python def set_age(age): if age < 0: raise ValueError(f"Age {age} is invalid: must be non-negative") return age def test_negative_age(): with pytest.raises(ValueError, match=r"must be non-negative"): set_age(-5) ``` Используйте сырые строки (`r"..."`) для regex-шаблонов. Частые паттерны: - `match=r"must be"` -- совпадение по подстроке - `match=r"\d+ is invalid"` -- число, за которым следует текст - `match=r"^Age"` -- сообщение должно начинаться с "Age" **Инспекция объекта исключения:** Сохраните объект `ExceptionInfo` после блока `with`: ```python with pytest.raises(ValueError) as excinfo: set_age(-5) excinfo.value # экземпляр исключения excinfo.type # класс исключения (ValueError) str(excinfo.value) # полное строковое представление excinfo.value.args # кортеж args, переданный исключению ``` **Подклассы исключений:** `pytest.raises(Exception)` поймает и `ValueError`, и `TypeError` и т.д. -- любой подкласс `Exception`. Используйте наиболее конкретный ожидаемый тип: ```python # слишком широко -- принимает любое исключение, скрывает баги with pytest.raises(Exception): risky() # правильно -- принимает только тот тип, для которого вы проектировали with pytest.raises(ValueError): risky() ``` **Проверка отсутствия исключения:** Чтобы убедиться, что код *не* выбрасывает, специальный синтаксис не нужен. Просто вызовите функцию обычно -- если она выбросит неожиданное исключение, pytest поймает его и провалит тест: ```python def test_valid_input(): result = set_age(25) # не должна выбрасывать assert result == 25 # проверьте и возвращаемое значение ```

Паттерны тестирования исключений

#
**Полный пример -- функция снятия денег с пользовательским исключением:** ```python import pytest class InsufficientFundsError(Exception): pass def withdraw(balance, amount): if not isinstance(amount, (int, float)): raise TypeError(f"Amount must be a number, got {type(amount).__name__}") if amount <= 0: raise ValueError(f"Amount must be positive, got {amount}") if amount > balance: raise InsufficientFundsError( f"Cannot withdraw {amount}: balance is only {balance}" ) return balance - amount ``` ```python # Тестируем каждый тип исключения отдельно def test_non_numeric_amount(): with pytest.raises(TypeError): withdraw(100, "fifty") def test_negative_amount(): with pytest.raises(ValueError, match=r"must be positive"): withdraw(100, -10) def test_zero_amount(): with pytest.raises(ValueError): withdraw(100, 0) def test_insufficient_funds(): with pytest.raises(InsufficientFundsError): withdraw(50, 100) def test_insufficient_funds_message(): with pytest.raises(InsufficientFundsError, match=r"balance is only 50"): withdraw(50, 100) ``` **Инспекция исключения после блока:** ```python def test_exception_details(): with pytest.raises(InsufficientFundsError) as excinfo: withdraw(50, 100) assert excinfo.type is InsufficientFundsError assert "100" in str(excinfo.value) # запрошенная сумма в сообщении assert "50" in str(excinfo.value) # доступный баланс в сообщении ``` **Проверка, что корректные данные работают (без исключения):** ```python def test_successful_withdrawal(): new_balance = withdraw(100, 40) assert new_balance == 60 def test_withdraw_entire_balance(): new_balance = withdraw(100, 100) assert new_balance == 0 ``` **Частая ошибка -- утверждения внутри блока with:** ```python # НЕПРАВИЛЬНО: если withdraw() немедленно выбрасывает, assert никогда не выполняется with pytest.raises(InsufficientFundsError): result = withdraw(50, 100) assert result is None # эта строка недостижима # ПРАВИЛЬНО: утверждения о результатах выносятся за пределы блока with with pytest.raises(InsufficientFundsError) as excinfo: withdraw(50, 100) assert "100" in str(excinfo.value) # выполняется после блока ``` Блок `with` завершается, как только выброшено исключение. Любой код после строки, выбросившей исключение, но ещё внутри блока `with`, недостижим.

Справочник по pytest.raises

#
```python import pytest # Базовый: проверка типа исключения with pytest.raises(SomeException): code_that_should_raise() # Проверка сообщения через regex with pytest.raises(SomeException, match=r"pattern"): code_that_should_raise() # Инспекция объекта исключения после блока with pytest.raises(SomeException) as excinfo: code_that_should_raise() excinfo.value # экземпляр исключения excinfo.type # класс исключения str(excinfo.value) # строковое представление # Принять несколько типов исключений with pytest.raises((ValueError, TypeError)): ambiguous_function() ``` **Частая ошибка -- утверждение внутри блока with:** ```python # НЕПРАВИЛЬНО: assert никогда не выполняется, если исключение выброшено первым with pytest.raises(ValueError): result = risky_call() assert result == 42 # пропускается! # ПРАВИЛЬНО: утверждения выносятся за пределы блока with with pytest.raises(ValueError): risky_call() # утверждения о результатах здесь, после подтверждения исключения ``` **Тестирование подклассов исключений:** `pytest.raises(Exception)` совпадает с `ValueError`, `TypeError` и т.д. (любым подклассом). Используйте точный тип, когда важно, какое именно исключение было выброшено.
01

Проверка ZeroDivisionError

#

Напишите `divide(a, b)`, которая возвращает `a / b`. Напишите тест, проверяющий, что при `b=0` выбрасывается `ZeroDivisionError`. Также напишите тест, проверяющий корректный результат при ненулевом `b`.

import pytest


def divide(a, b):
    return a / b


def test_divide_by_zero():
    pass

def test_divide_normal():
    pass
Решение
import pytest


def divide(a, b):
    return a / b


def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_divide_normal():
    assert divide(10, 2) == 5.0
    assert divide(-6, 3) == -2.0
    assert divide(0, 5) == 0.0
02

Проверка сообщения исключения через match

#

Напишите `set_age(age)`, которая выбрасывает `ValueError` с сообщением `'Age must be between 0 and 150'` при `age < 0` или `age > 150`. Напишите тесты, проверяющие, что `ValueError` выбрасывается, а его сообщение содержит `'between 0 and 150'` через параметр `match`.

import pytest


def set_age(age):
    pass  # выбросить ValueError с конкретным сообщением для некорректного возраста


def test_negative_age():
    pass

def test_age_too_large():
    pass
Решение
import pytest


def set_age(age):
    if age < 0 or age > 150:
        raise ValueError('Age must be between 0 and 150')
    return age


def test_negative_age():
    with pytest.raises(ValueError, match=r'between 0 and 150'):
        set_age(-1)

def test_age_too_large():
    with pytest.raises(ValueError, match=r'between 0 and 150'):
        set_age(151)

def test_valid_age():
    assert set_age(25) == 25
    assert set_age(0) == 0
    assert set_age(150) == 150
03

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

#

Напишите `parse_positive(value)`, которая выбрасывает `TypeError`, если `value` не является числом, и `ValueError`, если `value <= 0`. Напишите отдельные тесты для каждого типа исключения, каждый из которых проверяет, что нужное исключение выбрасывается при нужных входных данных.

import pytest


def parse_positive(value):
    pass  # TypeError для не-чисел, ValueError для неположительных


def test_non_number_raises_type_error():
    pass

def test_non_positive_raises_value_error():
    pass
Решение
import pytest


def parse_positive(value):
    if not isinstance(value, (int, float)):
        raise TypeError(f'Expected a number, got {type(value).__name__}')
    if value <= 0:
        raise ValueError(f'Value must be positive, got {value}')
    return value


def test_non_number_raises_type_error():
    with pytest.raises(TypeError):
        parse_positive('five')
    with pytest.raises(TypeError):
        parse_positive(None)

def test_non_positive_raises_value_error():
    with pytest.raises(ValueError):
        parse_positive(0)
    with pytest.raises(ValueError):
        parse_positive(-10)

def test_valid_input():
    assert parse_positive(5) == 5
    assert parse_positive(0.1) == pytest.approx(0.1)
04

Тестирование пользовательского исключения

#

Определите `InsufficientFundsError(Exception)` и `withdraw(balance, amount)`, которая выбрасывает его при `amount > balance`. Напишите тест, проверяющий выброс пользовательского исключения, и ещё один с `match`, проверяющий, что сообщение содержит запрошенную сумму.

import pytest


class InsufficientFundsError(Exception):
    pass


def withdraw(balance, amount):
    pass  # выбросить InsufficientFundsError при amount > balance


def test_raises_custom_exception():
    pass

def test_exception_mentions_amount():
    pass
Решение
import pytest


class InsufficientFundsError(Exception):
    pass


def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(
            f'Cannot withdraw {amount}: only {balance} available'
        )
    return balance - amount


def test_raises_custom_exception():
    with pytest.raises(InsufficientFundsError):
        withdraw(50, 100)

def test_exception_mentions_amount():
    with pytest.raises(InsufficientFundsError, match=r'100'):
        withdraw(50, 100)

def test_successful_withdrawal():
    assert withdraw(100, 40) == 60
05

Проверка отсутствия исключения

#

Напишите `safe_divide(a, b)`, которая возвращает `a / b` при `b != 0` и возвращает `0` при `b == 0` -- без исключения в обоих случаях. Напишите тесты для обоих путей, проверяющие корректные возвращаемые значения.

import pytest


def safe_divide(a, b):
    pass  # вернуть a/b обычно, вернуть 0 если b равно 0


def test_normal_division():
    pass

def test_zero_denominator_returns_zero():
    pass
Решение
def safe_divide(a, b):
    if b == 0:
        return 0
    return a / b


def test_normal_division():
    assert safe_divide(10, 2) == 5.0
    assert safe_divide(-6, 3) == -2.0

def test_zero_denominator_returns_zero():
    result = safe_divide(10, 0)
    assert result == 0

def test_zero_numerator():
    assert safe_divide(0, 5) == 0.0