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 # також перевіряємо значення, що повертається ```

Патерни тестування винятків

#
**Повний приклад: перевірка кількох типів винятків:** Розглянемо функцію `withdraw(balance, amount)`, яка може підіймати кілька різних винятків: ```python class InsufficientFundsError(Exception): pass def withdraw(balance, amount): if not isinstance(amount, (int, float)): raise TypeError(f"Amount must be numeric, got {type(amount).__name__}") if amount < 0: raise ValueError("Amount must be non-negative") if amount == 0: raise ValueError("Amount must be greater than zero") if amount > balance: raise InsufficientFundsError(f"Cannot withdraw {amount}, only {balance} available") return balance - amount ``` Тестуємо кожен тип винятку окремо: ```python def test_non_numeric_amount(): with pytest.raises(TypeError): withdraw(100, "fifty") def test_negative_amount(): with pytest.raises(ValueError): 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"Cannot withdraw"): 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_valid_withdrawal(): result = withdraw(100, 30) assert result == 70 def test_withdraw_exact_balance(): result = withdraw(50, 50) assert result == 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) # виконується після блоку ```

Довідник: 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`. Python підіймає `ZeroDivisionError` природно при `b == 0`. Напишіть два тести: `test_divide_by_zero` -- перевіряє підняття `ZeroDivisionError`, і `test_divide_normal` -- перевіряє правильний результат для коректного вводу.

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(9, 3) == 3.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(200)

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


def test_parse_positive_type_error():
    pass


def test_parse_positive_value_error():
    pass


def test_valid_input():
    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_parse_positive_type_error():
    with pytest.raises(TypeError):
        parse_positive('five')
    with pytest.raises(TypeError):
        parse_positive(None)

def test_parse_positive_value_error():
    with pytest.raises(ValueError):
        parse_positive(-3)
    with pytest.raises(ValueError):
        parse_positive(0)

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


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


def test_normal_division():
    pass


def test_zero_denominator_returns_zero():
    pass


def test_zero_numerator():
    pass
Рішення
import pytest


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(9, 3) == 3.0


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


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