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.
В этом уроке:
pytest.raises(Exc):— context-manager-style для assert raise.match='regex'— substring match черезre.search(NOTre.match— Pitfall 18).- Cross-link M07 урок 06 — typed custom Exception ↔
pytest.raises(MyError, ...). - Capturing exception value —
as exc_infoaccess к.value/.type/.tb. - Browser challenge framing — function-call mode wraps
try/exceptвsolve(). pytest.raisesxfail 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 делает:
- Body внутри
with pytest.raises(...)исполняется. - Если специфицированный exception (или subclass) raised — test PASSED.
- Если другой exception raised — pytest propagates (test fails с unexpected exception).
- Если никакой 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.py — RaisesContext + _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 18 — match= использует re.search (NOT re.match):
| Function | Behavior |
|---|---|
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.
Cross-link M07 урок 06 — typed custom Exception ↔ pytest.raises
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’ом коммуницируем:
- Valid path — return int.
InvalidFormatError— specific subtype для “not parseable” — distinguished от out-of-range.OutOfRangeError— specific subtype для range violation.- 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):
| Field | Type | Description |
|---|---|---|
.type | type[Exception] | Actual exception class |
.value | Exception | Exception instance |
.tb | TracebackType | Traceback object |
str(.value) | str | Exception 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:
| testCase | input | expectedOutput | What happens |
|---|---|---|---|
| tc1 | (10, 2) | 5.0 | solve((10, 2)) → returns 5.0 (no exception) |
| tc2 | (5, 0) | ZeroDivisionError: cannot divide by zero | divide(5, 0) raises → except catches → returns formatted string |
| tc3 | (-10, 2) | -5.0 | normal 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=
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.pyExpected 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 ==============================Что мы проверили:
- Successful path —
divide(10, 2) == 5.0(no exception). pytest.raises(Exc, match=r'...')— assert exception type + message match.re.searchsemantics — substring'divide by'matches anywhere в'cannot divide by zero'message.
Bonus — попробуйте сломать tests:
- Убрать
raiseвdividebody →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.).
Ключевые выводы
pytest.raises(Exc):— context-manager assert raise. Subclass-friendly (ParentcatchesChild).Failed: DID NOT RAISEесли no raise.match='regex'используетre.search(Pitfall 18 — NOTre.match). Substring match anywhere в message. Use raw strings + escape special chars (.,(, etc.).- Cross-link M07 урок 06 (PYTH-09) — typed custom Exception classes (
ValidationError,InvalidFormatError) ↔pytest.raises(SpecificSubtype, match=...)для typed error testing. M07 hierarchy → M08 verification. as exc_info— capture.type/.value/.tbдля structured attribute checks. Use sparingly —match=достаточен для simple cases.- Browser challenge framing — function-call mode wraps
try/exceptвsolve()→ returns string. pytest equivalent —with pytest.raises(Exc, match=...): .... Same mental model. pytest.raises≠xfail—raisesдля 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.