Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 09.04 · 20 мин
Средний
pytestpytest.raisesmatch=regexre.search vs re.matchPitfall 18Typed exceptionsPYTH-09 cross-linkZeroDivisionError

pytest.raises и typed exceptions — cross-link M07 урок 06 PYTH-09

В этом уроке — testing exceptional paths. М07 урок 06 дал нам typed exception hierarchy (class ValidationError(Exception): ...), теперь — как test’ить, что function поднимает specific exception с specific message.

В этом уроке:

  1. pytest.raises(Exc): — context-manager-style для assert raise.
  2. match='regex' — substring match через re.search (NOT re.match — Pitfall 18).
  3. Cross-link M07 урок 06 — typed custom Exception ↔ pytest.raises(MyError, ...).
  4. Capturing exception valueas exc_info access к .value / .type / .tb.
  5. Browser challenge framing — function-call mode wraps try/except в solve().
  6. pytest.raises xfail vs raises — when to use which.

pytest.raises(Exc): — context-manager-style

pytest.raises — context manager, который assert’ит, что body поднимает exception определённого type:

import pytest


def divide(a: int, b: int) -> float:
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b


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

Что pytest делает:

  1. Body внутри with pytest.raises(...) исполняется.
  2. Если специфицированный exception (или subclass) raised — test PASSED.
  3. Если другой exception raised — pytest propagates (test fails с unexpected exception).
  4. Если никакой exception не raised — pytest fails с Failed: DID NOT RAISE <class 'ZeroDivisionError'>.

Subclass matching — works just like try/except:

class ValidationError(Exception): pass
class InvalidFormatError(ValidationError): pass

def test_subclass_matches_parent():
    with pytest.raises(ValidationError):     # ← parent class
        raise InvalidFormatError('xyz')        # ← child class — matches

pytest.raises(Parent) ловит любого child. Same semantics as plain try/except Parent: ....

Cite: pytest 8 docs — pytest.raises; _pytest/python_api.pyRaisesContext + _pytest/_code/code.py:ExceptionInfo.


match='regex' — substring match через re.search (Pitfall 18)

match= keyword argument — regex pattern, который должен match’нуться внутри exception message:

def test_divide_by_zero_message():
    with pytest.raises(ZeroDivisionError, match='cannot divide by zero'):
        divide(10, 0)

Pitfall 18match= использует re.search (NOT re.match):

FunctionBehavior
re.match(pattern, string)matches только at start of string
re.search(pattern, string)matches anywhere в string

Empirical:

# Exception message: 'Error in module X: cannot divide by zero'
import re
print(re.match('cannot divide', 'Error in module X: cannot divide by zero'))
# None  (match — нужно at start)

print(re.search('cannot divide', 'Error in module X: cannot divide by zero'))
# <re.Match object; span=(19, 32), match='cannot divide'>  (works anywhere)

match= argument в pytest.raises использует re.search semantics — substring matches. Это forgiving для long messages с prefix:

def test_match_anywhere():
    with pytest.raises(ValueError, match=r'invalid age'):
        raise ValueError('Module X reported: invalid age: -5')   # ← passes (substring found)

Pitfall — re.search ещё treats pattern as regex, поэтому special characters должны быть escaped:

# Exception message: 'value: 3.14 out of range'

# WRONG — '.' в regex matches любой char (works coincidentally, but fragile):
with pytest.raises(ValueError, match='3.14'):    # matches '3X14' too!
    raise ValueError('value: 3.14 out of range')

# CORRECT — escape с raw string + \. :
with pytest.raises(ValueError, match=r'3\.14'):  # matches только literal '3.14'
    raise ValueError('value: 3.14 out of range')

Pragmatic rule: always use raw strings (r'...') для match patterns, и escape special characters (., (, [, ?, etc.) если хотите literal match.

Cite: pytest 8 docs — match parameter; re.search Python docs.


Recall M07 урок 06 (07-type-hints/06-typed-exceptions.mdx) — мы создали typed exception hierarchy:

# From M07 урок 06 — PYTH-09 home
class ValidationError(Exception):
    """Base class for all validation errors."""


class InvalidFormatError(ValidationError):
    """Raised when input has wrong format (not parseable)."""


class OutOfRangeError(ValidationError):
    """Raised when value is out of expected range."""


def validate_age(s: str) -> int:
    try:
        n = int(s)
    except ValueError:
        raise InvalidFormatError(f'not a number: {s!r}')
    if not 0 <= n <= 150:
        raise OutOfRangeError(f'age {n} out of range [0, 150]')
    return n

Now — pytest tests для этой hierarchy:

import pytest


class TestValidateAge:
    """Test class — pytest collects test_* methods (no __init__!)."""

    def test_valid_int_returns_int(self):
        assert validate_age('42') == 42

    def test_invalid_format_raises_specific_subtype(self):
        # Specific subtype check — captures intent
        with pytest.raises(InvalidFormatError, match=r"not a number: 'abc'"):
            validate_age('abc')

    def test_out_of_range_raises_specific_subtype(self):
        with pytest.raises(OutOfRangeError, match=r'age 200 out of range'):
            validate_age('200')

    def test_invalid_format_caught_by_parent(self):
        # Parent class also matches — useful для catch-all path
        with pytest.raises(ValidationError):
            validate_age('xyz')

Что мы tests’ом коммуницируем:

  1. Valid path — return int.
  2. InvalidFormatError — specific subtype для “not parseable” — distinguished от out-of-range.
  3. OutOfRangeError — specific subtype для range violation.
  4. Parent class matching работает — catch-all except ValidationError ловит обе sub-types.

Это — typed error API в action. M07 урок 06 дал hierarchy + chaining (raise X from e); M08 урок 04 даёт тестирование этой hierarchy через pytest.raises(SpecificType, match=...).

Cite: M07 урок 06 (PYTH-09 home — Lib/builtins.py Exception hierarchy + PEP 3134 chaining); pytest 8 — pytest.raises matches subclasses.


Capturing exception value — as exc_info

Иногда нужно inspect exception value после raise — as exc_info:

def test_exception_attributes():
    with pytest.raises(ValidationError) as exc_info:
        validate_age('999')

    # exc_info — pytest's ExceptionInfo wrapper
    assert exc_info.type is OutOfRangeError    # actual type
    assert 'out of range' in str(exc_info.value)
    assert isinstance(exc_info.value, ValidationError)  # parent check

exc_info API (_pytest/_code/code.py:ExceptionInfo):

FieldTypeDescription
.typetype[Exception]Actual exception class
.valueExceptionException instance
.tbTracebackTypeTraceback object
str(.value)strException message

Use case: когда нужно verify не просто type/message, а complex attributes:

class ValidationError(Exception):
    def __init__(self, msg: str, field: str, value: object):
        super().__init__(msg)
        self.field = field
        self.value = value


def test_exception_carries_field_metadata():
    with pytest.raises(ValidationError) as exc_info:
        raise ValidationError('out of range', field='age', value=999)

    err = exc_info.value
    assert err.field == 'age'           # ← custom attribute
    assert err.value == 999             # ← custom attribute
    assert 'out of range' in str(err)

Pragmatic rule: используйте exc_info только когда нужны structured attributes; для простых message checks — match= параметр достаточен.


Browser challenge framing — function-call mode wraps try/except

В browser challenges Module 08 (Pyodide) не доступен pytest.raises. Поэтому педагогически мы ставим try/except внутри solve() функции:

# Browser challenge skeleton:
def divide(a: int, b: int) -> float:
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b


def solve(args):
    a, b = args
    try:
        return divide(a, b)
    except ZeroDivisionError as e:
        return f'ZeroDivisionError: {e}'

Платформа (testRunner.ts) запускает solve(<input>) и compares output с expectedOutput:

testCaseinputexpectedOutputWhat happens
tc1(10, 2)5.0solve((10, 2)) → returns 5.0 (no exception)
tc2(5, 0)ZeroDivisionError: cannot divide by zerodivide(5, 0) raises → except catches → returns formatted string
tc3(-10, 2)-5.0normal path

Эквивалент в pytest:

# Same logic в pytest (run-on-your-machine):
import pytest

def test_divide_returns_float():
    assert divide(10, 2) == 5.0

def test_divide_negative():
    assert divide(-10, 2) == -5.0

def test_divide_by_zero_raises_with_message():
    with pytest.raises(ZeroDivisionError, match='cannot divide by zero'):
        divide(5, 0)

Параллель: try/except в solve() capture’ает exception → returns string → platform compares. pytest with pytest.raises(...) assert’ит exception → no string capture, direct check.

Это — same shape, разные surfaces. Browser challenges педагогически тренируют the mental model typed exception testing — local pytest.raises затем drop-in replacement.

Browser challenge py-m08-04-code-1 в matching quiz — exactly этот pattern (divide + solve с try/except + 3 testCases including (5, 0) ZeroDivisionError case).

Cite: src/lib/testRunner.ts lines 70-77 (buildPyHarness) + 207-208 (actual = result.success ? actual : (result.error ?? 'Execution failed')).


Run-on-Your-Machine — pytest.raises с match=

TIP

Run-on-Your-Machine: pytest.raises(Exc, match=r’pattern’) в action

Установите pytest (если ещё не из урока 02):

pip install pytest

Создайте test_divide.py:

# test_divide.py
import pytest


def divide(a: int, b: int) -> float:
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b


def test_divide_returns_float():
    assert divide(10, 2) == 5.0


def test_divide_negative_returns_negative():
    assert divide(-10, 2) == -5.0


def test_divide_by_zero_raises_with_specific_message():
    with pytest.raises(ZeroDivisionError, match=r'cannot divide by zero'):
        divide(5, 0)


def test_match_does_not_match_other_messages():
    """Verify match= использует re.search semantics — substring anywhere."""
    with pytest.raises(ZeroDivisionError, match=r'divide by'):
        divide(5, 0)    # ← passes — 'divide by' is substring of message

Запустите:

pytest -v test_divide.py

Expected output:

test_divide.py::test_divide_returns_float PASSED                         [ 25%]
test_divide.py::test_divide_negative_returns_negative PASSED             [ 50%]
test_divide.py::test_divide_by_zero_raises_with_specific_message PASSED  [ 75%]
test_divide.py::test_match_does_not_match_other_messages PASSED          [100%]

============================== 4 passed in 0.02s ==============================

Что мы проверили:

  1. Successful pathdivide(10, 2) == 5.0 (no exception).
  2. pytest.raises(Exc, match=r'...') — assert exception type + message match.
  3. re.search semantics — substring 'divide by' matches anywhere в 'cannot divide by zero' message.

Bonus — попробуйте сломать tests:

  • Убрать raise в divide body → Failed: DID NOT RAISE <class 'ZeroDivisionError'>.
  • Поменять message на 'BAD MESSAGE'AssertionError: Pattern 'cannot divide by zero' does not match 'BAD MESSAGE'.

Это — typed exception verification в действии. Same mental model используется в browser challenge py-m08-04-code-1 через try/except в solve().


Recipe — production typed exception tests

End-to-end recipe combining lessons 02-04:

# tests/test_validate_age.py
import pytest
from mypkg.validation import (
    ValidationError,
    InvalidFormatError,
    OutOfRangeError,
    validate_age,
)


@pytest.mark.parametrize('valid_str,expected_int', [
    pytest.param('42', 42, id='middle_aged'),
    pytest.param('0', 0, id='boundary_low'),
    pytest.param('150', 150, id='boundary_high'),
])
def test_validate_age_valid_returns_int(valid_str: str, expected_int: int) -> None:
    assert validate_age(valid_str) == expected_int


@pytest.mark.parametrize('invalid_input,expected_subtype,match_pattern', [
    pytest.param('abc', InvalidFormatError, r"not a number: 'abc'", id='not_numeric'),
    pytest.param('', InvalidFormatError, r"not a number: ''", id='empty'),
    pytest.param('-1', OutOfRangeError, r'age -1 out of range', id='negative'),
    pytest.param('200', OutOfRangeError, r'age 200 out of range', id='over_max'),
])
def test_validate_age_invalid_raises_specific_subtype(
    invalid_input: str,
    expected_subtype: type[ValidationError],
    match_pattern: str,
) -> None:
    with pytest.raises(expected_subtype, match=match_pattern):
        validate_age(invalid_input)


def test_all_invalid_inputs_caught_by_parent_class() -> None:
    """Parent class catch-all for callers who don't distinguish subtypes."""
    for invalid in ['abc', '', '-1', '200']:
        with pytest.raises(ValidationError):
            validate_age(invalid)

Test invocation count: 3 (valid) + 4 (invalid) + 1 (catch-all) = 8 invocations from 3 functions. Compact, parametrized, type-hierarchy-aware.

Output:

test_validate_age.py::test_validate_age_valid_returns_int[middle_aged] PASSED
test_validate_age.py::test_validate_age_valid_returns_int[boundary_low] PASSED
test_validate_age.py::test_validate_age_valid_returns_int[boundary_high] PASSED
test_validate_age.py::test_validate_age_invalid_raises_specific_subtype[not_numeric] PASSED
test_validate_age.py::test_validate_age_invalid_raises_specific_subtype[empty] PASSED
test_validate_age.py::test_validate_age_invalid_raises_specific_subtype[negative] PASSED
test_validate_age.py::test_validate_age_invalid_raises_specific_subtype[over_max] PASSED
test_validate_age.py::test_all_invalid_inputs_caught_by_parent_class PASSED

============================== 8 passed in 0.04s ==============================

Brief — pytest.xfail vs pytest.raises

Не путайте:

  • pytest.raises(Exc): — assert’ит, что body raises specific exception. Passes if raised; fails если no raise.
  • @pytest.mark.xfail — test expected to fail (любой reason). Passes if test FAILS; warning if test PASSES.
@pytest.mark.xfail(reason='known bug — fixed in v2.5')
def test_buggy_feature():
    assert buggy() == 'expected_value'    # ← если fails → XFAIL (PASSED-equivalent)


def test_zero_division_raises():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)                       # ← должен raise — иначе fails

xfail для acknowledged bugs / not-yet-implemented features. raises для expected error paths (validation, type checking, etc.).


Ключевые выводы

  1. pytest.raises(Exc): — context-manager assert raise. Subclass-friendly (Parent catches Child). Failed: DID NOT RAISE если no raise.
  2. match='regex' использует re.search (Pitfall 18 — NOT re.match). Substring match anywhere в message. Use raw strings + escape special chars (., (, etc.).
  3. Cross-link M07 урок 06 (PYTH-09) — typed custom Exception classes (ValidationError, InvalidFormatError) ↔ pytest.raises(SpecificSubtype, match=...) для typed error testing. M07 hierarchy → M08 verification.
  4. as exc_info — capture .type / .value / .tb для structured attribute checks. Use sparingly — match= достаточен для simple cases.
  5. Browser challenge framing — function-call mode wraps try/except в solve() → returns string. pytest equivalent — with pytest.raises(Exc, match=...): .... Same mental model.
  6. pytest.raisesxfailraises для expected error paths; xfail для acknowledged bugs / TODO features.

Дальше — mocks (урок 05): unittest.mock.MagicMock direct + patch() decorator + monkeypatch fixture (Pitfall 17 — ScopeMismatch). Run-on-Your-Machine #3 (mocks demo). Browser challenge py-m08-05-code-1 — MagicMock direct usage в Pyodide stdlib.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7