Python · Testing with pytest · Beginner

Testing Exceptions

5 tasks

Assert that code raises the right exceptions with pytest.raises.

Testing That Code Fails Correctly

#
A function that raises an exception on invalid input is behaving correctly — raising is part of its contract. Testing that behavior is just as important as testing the happy path. **pytest.raises — the basic pattern:** ```python import pytest def divide(a, b): return a / b def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): divide(10, 0) ``` `pytest.raises` is a context manager. Inside the `with` block you call code that *should* raise. Three outcomes: | What happens | Test result | |---|---| | Expected exception raised | PASSED | | No exception raised | FAILED — `DID NOT RAISE` | | Different exception raised | FAILED — the wrong exception propagates | The third case is important: if `divide(10, 0)` raised `ValueError` instead of `ZeroDivisionError`, the test would fail with the unexpected `ValueError` in the output. You can't accidentally pass with the wrong exception type. **Checking the exception message with `match`:** The `match` parameter takes a **regex** pattern matched against `str(exception)`. You don't need to match the entire message — a distinctive substring is enough: ```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) ``` Use raw strings (`r"..."`) for regex patterns. Common patterns: - `match=r"must be"` — substring match - `match=r"\d+ is invalid"` — match a number followed by text - `match=r"^Age"` — message must start with "Age" **Inspecting the exception object:** Capture the `ExceptionInfo` object after the `with` block: ```python with pytest.raises(ValueError) as excinfo: set_age(-5) excinfo.value # the exception instance excinfo.type # the exception class (ValueError) str(excinfo.value) # the full string representation excinfo.value.args # the args tuple passed to the exception ``` **Exception subclasses:** `pytest.raises(Exception)` would also catch `ValueError`, `TypeError`, etc. — any subclass of `Exception`. Use the most specific exception type you expect: ```python # too broad — accepts any exception, hides bugs with pytest.raises(Exception): risky() # correct — only accepts the specific type you designed for with pytest.raises(ValueError): risky() ``` **Asserting no exception:** Testing that code does *not* raise requires no special syntax. Just call the function normally — if it raises unexpectedly, pytest catches the exception and fails the test: ```python def test_valid_input(): result = set_age(25) # should not raise assert result == 25 # verify the return value too ```

Exception Testing Patterns

#
**Complete example — a withdrawal function with custom exception:** ```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 # Test each exception type separately 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) ``` **Inspecting the exception after the block:** ```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) # requested amount in message assert "50" in str(excinfo.value) # available balance in message ``` **Testing that valid input works (no exception):** ```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 ``` **Common mistake — putting assertions inside the with block:** ```python # WRONG: if withdraw() raises immediately, the assert never runs with pytest.raises(InsufficientFundsError): result = withdraw(50, 100) assert result is None # this line is dead code # RIGHT: assertions about results go outside the with block with pytest.raises(InsufficientFundsError) as excinfo: withdraw(50, 100) assert "100" in str(excinfo.value) # runs after the block ``` The `with` block ends as soon as the exception is raised. Any code after the raising line but still inside the `with` block is unreachable.

pytest.raises Reference

#
```python import pytest # Basic: assert exception type with pytest.raises(SomeException): code_that_should_raise() # Check message with regex with pytest.raises(SomeException, match=r"pattern"): code_that_should_raise() # Inspect exception object after the block with pytest.raises(SomeException) as excinfo: code_that_should_raise() excinfo.value # the exception instance excinfo.type # the exception class str(excinfo.value) # string representation # Accept multiple exception types with pytest.raises((ValueError, TypeError)): ambiguous_function() ``` **Common mistake — assertion inside the with block:** ```python # WRONG: assert never runs if the exception fires first with pytest.raises(ValueError): result = risky_call() assert result == 42 # skipped! # RIGHT: assertions go outside the with block with pytest.raises(ValueError): risky_call() # result assertions here, after confirming the exception happened ``` **Testing exception subclasses:** `pytest.raises(Exception)` matches `ValueError`, `TypeError`, etc. (any subclass). Use the exact type when you care which exception was raised.
01

Assert ZeroDivisionError

#

Write `divide(a, b)` that returns `a / b`. Write a test that asserts `ZeroDivisionError` is raised when `b=0`. Also write a test that verifies the function returns correct results when `b` is non-zero.

import pytest


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


def test_divide_by_zero():
    pass

def test_divide_normal():
    pass
Solution
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

Check Exception Message with match

#

Write `set_age(age)` that raises `ValueError` with message `'Age must be between 0 and 150'` when `age < 0` or `age > 150`. Write tests asserting the ValueError is raised and its message contains `'between 0 and 150'` using the `match` parameter.

import pytest


def set_age(age):
    pass  # raise ValueError with specific message for invalid ages


def test_negative_age():
    pass

def test_age_too_large():
    pass
Solution
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

Test Multiple Exception Types

#

Write `parse_positive(value)` that raises `TypeError` if `value` is not a number and `ValueError` if `value <= 0`. Write separate tests for each exception type, each verifying the right exception is raised for the right input.

import pytest


def parse_positive(value):
    pass  # TypeError for non-numbers, ValueError for non-positive


def test_non_number_raises_type_error():
    pass

def test_non_positive_raises_value_error():
    pass
Solution
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

Test a Custom Exception

#

Define `InsufficientFundsError(Exception)` and `withdraw(balance, amount)` that raises it when `amount > balance`. Write a test asserting the custom exception is raised, and another using `match` to verify the message mentions the requested amount.

import pytest


class InsufficientFundsError(Exception):
    pass


def withdraw(balance, amount):
    pass  # raise InsufficientFundsError when amount > balance


def test_raises_custom_exception():
    pass

def test_exception_mentions_amount():
    pass
Solution
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

Assert No Exception Is Raised

#

Write `safe_divide(a, b)` that returns `a / b` when `b != 0` and returns `0` when `b == 0` — no exception in either case. Write tests for both paths, verifying the correct return values.

import pytest


def safe_divide(a, b):
    pass  # return a/b normally, return 0 if b is 0


def test_normal_division():
    pass

def test_zero_denominator_returns_zero():
    pass
Solution
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