Mathew K Analytics

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…

⬇ Download notebook

📓 Full notebook

Download .ipynb

pytest 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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpuy5_vrut_test.py::test_exc_info_value_and_type PASSED [100%]

============================== 1 passed in 0.21s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpwjhn7d0z_test.py::test_chained_exception_preserves_cause PASSED [100%]

============================== 1 passed in 0.11s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpb37aa_xj_test.py::test_subclass_caught_by_base_class PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpb37aa_xj_test.py::test_precise_type_still_checkable PASSED [100%]

============================== 2 passed in 0.11s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmplp43qpzo_test.py::test_recwarn_captures_multiple_warnings PASSED [100%]

============================== 1 passed in 0.11s ==============================
0

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)
============================= test session starts =============================
collecting ... collected 4 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpnujw2n80_test.py::test_valid_signup_passes PASSED [ 25%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpnujw2n80_test.py::test_bad_email_raises_specific_error PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpnujw2n80_test.py::test_weak_password_raises_specific_error PASSED [ 75%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpnujw2n80_test.py::test_both_errors_are_signup_errors PASSED [100%]

============================== 4 passed in 0.14s ==============================
0

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.