Chapter 5

Exceptions & Testing

5.0 Intro · 5.1 Exceptions · 5.2 Unit Testing

← → or Space to navigate · F for fullscreen

Why Exceptions and Testing?

Handle failures gracefully and verify that code does what it claims

Two Kinds of Problems

Runtime failures

The program stops because Python raises an exception.

age = int("twenty")
# ValueError

Exception handling lets the program recover or report a useful message.

Semantic bugs

The program runs, but the answer is wrong.

def average(nums):
    return sum(nums) / (len(nums) - 1)
# wrong formula

Tests catch wrong behavior before users do.

Chapter 5 Toolkit

Tracebacks Follow the error path to the failing line.
try / except Recover from expected runtime failures.
raise Signal invalid input or state clearly.
logging Record what happened without cluttering output.
assert Check assumptions while developing.
tests Make behavior repeatable and verifiable.

5.1 Exceptions

Error types, tracebacks, try/except, else/finally, raising exceptions, debugging, and logging

Types of Errors

Error type When it occurs Example
Syntax error Code violates Python grammar; the program never starts Missing colon, unmatched quotes
Runtime error (exception) Something goes wrong while the program runs Division by zero, file not found
Logic (semantic) error Code runs but produces the wrong result Wrong formula

Runtime errors raise an exception. If nothing handles it, the program stops and prints a traceback.

Reading Tracebacks

  • Start at the bottom for the exception type and message.
  • Look upward to find the line in your code that failed.
  • Tracebacks show the call stack: who called whom.

A traceback is a map, not just an error message.

def parse_age(text):
    return int(text)

def register(raw_age):
    age = parse_age(raw_age)
    return {"age": age}

register("twenty")
# ValueError: invalid literal for int()

try / except

  • Put risky code in the try block.
  • Catch specific exception types.
  • Use except ValueError as e to inspect the error.
  • Return, retry, or explain the failure.

Avoid bare except:. It can hide real bugs.

def parse_int(text):
    try:
        return int(text)
    except ValueError:
        print(f"{text!r} is not an integer")
        return None

print(parse_int("42"))     # 42
print(parse_int("hello"))  # None

Multiple Exceptions

Different failures deserve different responses.

Exception Common cause
ValueError Invalid value, like int("x")
FileNotFoundError Missing file path
KeyError Missing dictionary key
ZeroDivisionError Division by zero
def get_score(scores, name):
    try:
        return scores[name]
    except KeyError:
        return "missing student"
    except TypeError:
        return "scores must be a dict"

else and finally

  • else runs only if no exception occurred.
  • finally always runs, exception or not.
  • Use finally for cleanup.
try:
    value = int("42")
except ValueError:
    print("bad input")
else:
    print("converted:", value)
finally:
    print("done")

Raising Exceptions

  • Use raise when a function cannot honor its contract.
  • Choose a specific built-in exception when possible.
  • Create custom exceptions for domain-specific failures.
class OverdraftError(Exception):
    pass

def withdraw(balance, amount):
    if amount < 0:
        raise ValueError("amount must be positive")
    if amount > balance:
        raise OverdraftError("insufficient funds")
    return balance - amount

Debugging

Exceptions tell you that something went wrong. Debugging finds out why.

  • Print-statement debugging: temporary print() calls, prefixed DEBUG:, show values and which lines ran.
  • Assertions: assert documents an assumption and fails right where it breaks.
  • Bisection: check the data halfway through; keep halving until you isolate the bug.

assert is for your own bugs. python -O skips every assert, so use raise to reject bad input.

def average(nums):
    assert len(nums) > 0, "nums is empty"
    total = sum(nums)
    print(f"DEBUG: total={total}, n={len(nums)}")
    return total / len(nums)

Logging

Logging records diagnostic information without scattered print() calls.

Level Use
DEBUG Detailed developer info
INFO Normal progress
WARNING Unexpected but recoverable
ERROR Operation failed
import logging

logging.basicConfig(level=logging.INFO)

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        logging.error("division by zero")
        return None

5.2 Unit Testing

pytest, unittest, doctest, fixtures, mocking, parametrization, and coverage

What is a Unit Test?

  • A small automated check for one behavior.
  • Tests one function, method, or class behavior.
  • Should be repeatable and independent.
  • The foundation under integration and end-to-end tests.

Tests turn examples into executable evidence.

def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

pytest

  • Test files start with test_.
  • Test functions start with test_.
  • Use plain assert statements.

Run from the terminal with python -m pytest. In a notebook, start a cell with %%ipytest.

# test_calc.py
from calc import add, subtract, divide

def test_add():
    assert add(2, 3) == 5

def test_subtract():
    assert subtract(10, 4) == 6

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

Testing Exceptions

Good tests check both normal behavior and expected failure behavior.

  • Use pytest.raises for expected exceptions.
  • Add match= to check the error message.
  • Test the contract, not implementation details.
import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("b cannot be zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="cannot be zero"):
        divide(10, 0)

unittest

  • Python's built-in testing framework.
  • Organizes tests inside classes.
  • Uses assertion methods like assertEqual.
  • setUp / tearDown run before / after each test.

python -m unittest discovers test*.py files automatically. You will see unittest in many older projects.

import unittest

class TestFruitBasket(unittest.TestCase):
    def setUp(self):
        # every test gets a fresh basket
        self.basket = ["apple", "banana"]

    def test_add_fruit(self):
        self.basket.append("cherry")
        self.assertEqual(len(self.basket), 3)

    def test_starts_with_two_fruits(self):
        self.assertEqual(len(self.basket), 2)

Doctests

  • Examples embedded in docstrings.
  • Useful for simple functions and documentation.
  • Best when outputs are short and stable.

Run with python -m doctest file.py -v or pytest --doctest-modules.

def square(x):
    """Return x squared.

    >>> square(4)
    16
    >>> square(-3)
    9
    """
    return x * x

More Testing Techniques

Tool Use
Mock Replace slow or external dependencies
Fixture Setup that pytest passes to a test, like monkeypatch
Parametrize Run one test with many inputs
Coverage Find lines and branches tests did not run

pytest --cov=my_module --cov-branch --cov-report=term-missing

import pytest

@pytest.mark.parametrize("s, expected", [
    ("racecar", True),
    ("hello",   False),
    ("",        True),
])
def test_is_palindrome(s, expected):
    assert is_palindrome(s) == expected

Mocking Dependencies

Tests should not hit the network, a database, or real files.

  • unittest.mock.patch swaps a function for a stand-in inside a with block.
  • pytest's monkeypatch fixture does the same for one test.

Patch the name where it is looked up. If shop.py does from pricing import get_price, patch shop.get_price, not pricing.get_price.

from unittest.mock import patch

with patch("shop.get_price", return_value=100.0):
    assert apply_discount("widget") == 90.0

def test_apply_discount(monkeypatch):
    monkeypatch.setattr("shop.get_price",
                        lambda item: 100.0)
    assert apply_discount("widget") == 90.0

Testing Strategy

Test these first

  • Normal cases
  • Boundary cases
  • Error cases
  • Previously fixed bugs

Keep tests useful

  • Small and focused
  • Readable names
  • Independent of test order
  • Fast enough to run often

Testing is design feedback: if code is hard to test, it is often doing too much.

Chapter 5 Quick Reference

Concept Key syntax / notes
Catch exception try: / except ValueError:
Exception object except Exception as e:
Cleanup finally always runs
Raise exception raise ValueError("message")
Custom exception class OutOfStockError(Exception): pass
Assertion assert condition, "message"
Logging logging.info(), logging.error()
pytest test def test_name(): assert result == expected
Expected exception with pytest.raises(Error, match="..."):
unittest class TestX(unittest.TestCase): with setUp
doctest Examples in docstrings with >>>
Mock patch("module.name"), monkeypatch.setattr(...)
Parametrize @pytest.mark.parametrize("x, expected", [...])

End of Chapter 5

Next: Chapter 6: Sequences

tracebacks · exceptions · logging · pytest · unittest · doctest