Lesson 16 · Pytest deep dive
pytest Tutorial #16: Testing Exceptions & Warnings
On testing real Python code with pytest. excinfo attributes, exception chaining, custom hierarchies, and recwarn. Let's get into it. pytest.raises(...) as…
- CoursePytest deep dive
- Lesson16 of 18
- FormatJupyter notebook · 6 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbpytest Deep-Dive, Video 16: Testing Exceptions and Warnings Deep-Dive#
- Video sixteen of an eighteen-part series on testing real Python code with pytest.
- exc_info attributes, exception chaining, custom hierarchies, and recwarn.
- Let's get into it.
Part 1: The Helper From Last Time#
import pytest, tempfile, os
def run_pytest(code_str, args=None):
with tempfile.NamedTemporaryFile(mode='w', suffix='_test.py', delete=False) as f:
f.write(code_str)
path = f.name
result = pytest.main([path, *(args or [])])
os.unlink(path)
return result
Part 2: exc_info - Inspecting the Caught Exception Directly#
code = '''
import pytest
def test_exc_info_value_and_type():
with pytest.raises(ValueError) as exc_info:
raise ValueError('bad value: 42')
assert str(exc_info.value) == 'bad value: 42'
assert exc_info.type is ValueError
assert isinstance(exc_info.value, ValueError)
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 3: Exception Chaining - raise ... from ...#
code = '''
import pytest
def parse_config_value(raw):
try:
return int(raw)
except ValueError as e:
raise RuntimeError('config value must be numeric') from e
def test_chained_exception_preserves_cause():
with pytest.raises(RuntimeError) as exc_info:
parse_config_value('not-a-number')
assert isinstance(exc_info.value.__cause__, ValueError)
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 4: Custom Exception Hierarchies#
code = '''
import pytest
class ValidationError(ValueError):
pass
class RequiredFieldError(ValidationError):
pass
def test_subclass_caught_by_base_class():
with pytest.raises(ValueError):
raise ValidationError('custom validation issue')
def test_precise_type_still_checkable():
with pytest.raises(ValidationError) as exc_info:
raise RequiredFieldError('missing field: email')
assert isinstance(exc_info.value, RequiredFieldError)
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 5: recwarn - Capturing Every Warning as a List#
code = '''
import warnings
def test_recwarn_captures_multiple_warnings(recwarn):
warnings.warn('first issue', UserWarning)
warnings.warn('second issue', DeprecationWarning)
assert len(recwarn) == 2
assert recwarn[0].category == UserWarning
assert recwarn[1].category == DeprecationWarning
assert str(recwarn[0].message) == 'first issue'
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Part 6: A Real Pattern - Precise Errors for a Validation Pipeline#
code = '''
import pytest
class SignupError(ValueError):
pass
class WeakPasswordError(SignupError):
pass
class InvalidEmailError(SignupError):
pass
def validate_signup(email, password):
if '@' not in email:
try:
raise ValueError('malformed email')
except ValueError as e:
raise InvalidEmailError(f'invalid email: {email}') from e
if len(password) < 8:
raise WeakPasswordError('password must be at least 8 characters')
return True
def test_valid_signup_passes():
assert validate_signup('user@example.com', 'longenough123') is True
def test_bad_email_raises_specific_error():
with pytest.raises(InvalidEmailError) as exc_info:
validate_signup('not-an-email', 'longenough123')
assert isinstance(exc_info.value.__cause__, ValueError)
def test_weak_password_raises_specific_error():
with pytest.raises(WeakPasswordError):
validate_signup('user@example.com', 'short')
def test_both_errors_are_signup_errors():
with pytest.raises(SignupError):
validate_signup('bad', 'short')
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
Wrap-Up: What You Learned#
- pytest.raises(...) as exc_info exposes exc_info.value and exc_info.type for deeper inspection.
- raise NewError(...) from original preserves the original exception as cause, inspectable in tests.
- A custom exception inheriting from a built-in one is still caught by pytest.raises on the built-in type.
- recwarn captures every warning raised during a test as an indexable, individually-inspectable list.
- A small custom exception hierarchy lets each specific failure reason get its own precisely-testable type.
- Testing against a shared base exception class verifies any of several related failures were raised.
- That wraps up testing exceptions and warnings deep-dive. Next up: CI Integration and Config.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



