Day 15 / 25 Exceptions, Testing & Mini-Project 3 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Programming Fundamentals/Day 15

Week 3 · Algorithms & Object-Oriented Python · Day 15

Exceptions, Testing & Mini-Project 3

Failing well, and proving you did. Tests are cheaper than debugging, and this is where that stops being a slogan.

Study time
4 hours
Reading
Think Python, Ch. 14.5
Focus
try / except / raise · pytest

By the end of today you can

  1. Use try / except / else / finally, and say what each part is for
  2. Catch the narrowest exception that makes sense, and never a bare except
  3. raise deliberately, with a message a stranger could act on
  4. Trace an exception up the call stack and predict where it is caught
  5. Write a custom exception class and say when it earns its place
  6. Write a pytest test, run it, and read the failure output
  7. Test the edges — empty, one, negative, wrong type
  8. Deliver Mini-Project 3

Today's videos

Watch each video, then work the matching sections below. Watching alone will not do it.

15A
Exceptions, Properly (120 min)
try/except/else/finally and what each is for · catching narrowly · why a bare except manufactures silent bugs · raising deliberately with an actionable message · propagation up the stack · custom exceptions that earn their place.
15B
Testing & Mini-Project 3 (120 min)
The known-answer habit, written down · pytest conventions · reading a failure · testing the edges first · pytest.raises · why every testable function in this course returns a value · the Library System build.

1try, except, and the ones people forget

Four clauses. Most people use two.

four_clauses.py
def read_config(path):
    try:
        f = open(path, encoding="utf-8")
    except FileNotFoundError:
        print(f"no config at {path}, using defaults")
        return {}
    else:
        # runs only if NOTHING was raised
        return parse(f.read())
    finally:
        # runs no matter what — success, failure, or return
        print("config load attempted")
ClauseRuns whenUse it for
tryAlwaysThe risky operation — keep it short
exceptThat exception was raisedRecovering, or reporting
elseNothing was raisedThe follow-on work, so it is not inside try
finallyAlways, even on returnCleanup you cannot skip
Why else matters more than it looks

Anything you put in the try block is protected by the except. If parse() happens to raise a FileNotFoundError of its own, you would silently swallow it and return {}. Moving it to else means the try covers exactly one line — the one you meant.

Catch narrowly

An empty except is how bugs hide for months

except: pass converts a loud, locatable failure into a silent wrong answer. Day 5 taught you that semantic errors are the expensive ones — a bare except is a machine for manufacturing them.

2raise, and propagation

An exception travels up until something catches it.

The design rule

Raise where the problem is detected. Catch where you know what to do about it. parse_age knows the string is bad but not whether that should stop the program. main knows the policy but not the details. Exceptions carry the news between them.

raising.py
def parse_age(raw):
    """Age as an int. Raises ValueError if it is not sensible."""
    age = int(raw)                       # may raise ValueError itself
    if age < 0 or age > 130:
        raise ValueError(f"age out of range: {age}")
    return age

Two things about that message. It says what was wrong and shows the offending value — "age out of range: 214" is actionable; "bad input" is not. And the function raises rather than printing, so a caller can decide what to do.

Custom exceptions

custom.py
class ContactBookError(Exception):
    """Anything this program raises deliberately."""


class DuplicateContact(ContactBookError):
    """A contact with that name already exists."""


# now a caller can be precise about what it handles
try:
    book.add(contact)
except DuplicateContact:
    book.update(contact)

A custom exception earns its place when a caller needs to tell your failure from a built-in one. If nobody will ever catch it specifically, raise ValueError and save yourself the class.

3pytest

The known-answer habit from Day 5, written down and run automatically.

You have been testing since Day 1 — running a program with values whose answer you already knew. A test is that, saved to a file so the computer does it for you every time.

test_stats.py
from stats import mean_of, count_above


def test_mean_of_typical():
    assert mean_of([12, 7, 25, 3]) == 11.75


def test_mean_of_single():
    assert mean_of([5]) == 5


def test_count_above_is_strict():
    """25 is not ABOVE 25."""
    assert count_above([12, 25], 25) == 0


def test_count_above_none_match():
    assert count_above([1, 2], 100) == 0
terminal
pip install pytest
pytest -q
.... [100%] 4 passed in 0.01s
ConventionRule
File nametest_*.py
Function nametest_*
AssertionPlain assert — pytest rewrites it to show the values
WhereA tests/ folder next to your code

Reading a failure

terminal
    def test_count_above_is_strict():
        """25 is not ABOVE 25."""
>       assert count_above([12, 25], 25) == 0
E       assert 1 == 0
E        +  where 1 = count_above([12, 25], 25)

test_stats.py:13: AssertionError

pytest shows the expression, the values it computed, and the line. That last line — where 1 = count_above([12, 25], 25) — is doing the work a print statement would have done, automatically.

What to test, in priority order

The edges first. Empty. One item. Zero. Negative. The maximum. The boundary value itself and the one either side of it. Bugs live at boundaries — a test of the obvious middle case rarely finds anything.

Now Day 6 pays off

Look at what is testable: mean_of, count_above, format_report. All of them return values. A function that prints cannot be tested — there is nothing to assert on. The return-versus-print distinction was never about style; this is what it was for.

4Mini-Project 3 — Tested Library System

Your third graded deliverable · 100 points

Everything from Week 3 in one program: classes, encapsulation, inheritance, exceptions and a real test suite.

Required behaviour

  1. A LibraryItem base class, with Book, DVD and Magazine subclasses.
  2. Each subclass has a different loan period and a different late-fee rule.
  3. A Library class holding items, with check_out, return_item and search.
  4. Invariants enforced in one place: no negative fees, no double check-out, no unknown item.
  5. Custom exceptions — ItemUnavailable, ItemNotFound — raised deliberately.
  6. Data persists to a file between runs.
  7. A pytest suite with at least 15 tests, covering every subclass and every raised exception.
CriterionPoints
Class design — sensible hierarchy, honest is-a20
Encapsulation — invariants enforced, @property where it earns its place15
Polymorphism — one call, different behaviour, no type-checking if-chain15
Exceptions — raised deliberately, caught narrowly15
Test suite — 15+ tests, edges covered25
Naming, structure, docstrings, PEP 810
A quarter of the marks are the tests

This is deliberate, and it reflects reality: a working program with no tests is worth less than a slightly smaller one you can change safely. Write the test for each behaviour before or immediately after you write the behaviour — not in a panic at the end.

Suggested build order

  1. LibraryItem and one subclass. Two tests. Run pytest and watch them pass.
  2. Add the other two subclasses. Test that the loan periods genuinely differ.
  3. Library with check_out. Test the happy path, then the double-check-out failure.
  4. Custom exceptions. Test each one with pytest.raises.
  5. Persistence. Test a save-then-load round trip.
  6. Edges: empty library, unknown item, zero-day loan, negative fee attempt.
  7. black, ruff, and read it once as a stranger.
test_raises.py
import pytest


def test_double_checkout_raises():
    library = Library()
    book = Book("Think Python", 292)
    library.add(book)
    library.check_out(book)

    with pytest.raises(ItemUnavailable):
        library.check_out(book)

pytest.raises is how you assert that something must fail. A test suite that only checks the happy path is testing half a program — and the half that was already working.

>_Python playground

A real Python interpreter running inside your browser. Nothing is installed, nothing is uploaded, nothing can break.

scratch.pypython not loaded
Values for input(), comma separated →
Output appears here. The first run takes a few seconds while Python loads.

Exercise set

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset day

Day 16 — Java: The Model Shift

Every word of public static void main, explained.

Continue →