Module 8 ended on an uncomfortable question: when you change a line of Papyrus tomorrow — a new discount, an extra field in sales.csv — how do you guarantee that everything still works? mypy's type hints verify that the types fit together, but not that final_price() returns 12.35 EUR rather than 12.36 EUR. Checking by hand every time doesn't scale. Software engineering's answer is to write code that checks the code: tests. In this lesson you'll understand why they are indispensable, what the anatomy of a test looks like, and what deserves testing first — before touching any framework.

Contents

  1. The cost of a bug: the morning Ana broke the member price
  2. What a test is: the AAA pattern (arrange-act-assert)
  3. Bare assert: the first tool and its limits
  4. The handcrafted forerunner: if __name__ == "__main__"
  5. Kinds of tests and the pyramid
  6. What to test first: happy paths, edges and expected errors
  7. Qualities of a good test

The cost of a bug: the morning Ana broke the member price

One Tuesday, Ana decides to "tidy up" the final_price() method in models.py a little. The original computed everything in one expression; she splits it into steps and, while she's at it, rounds the intermediate result "to keep it clean":

# Original version (correct): rounds ONCE, at the end
def final_price(self, member: bool = False) -> float:
    base = self.price * (1 - MEMBER_DISCOUNT) if member else self.price
    return round(base * (1 + BOOK_VAT), 2)

# Ana's "tidy" version (incorrect): rounds TWICE
def final_price(self, member: bool = False) -> float:
    base = self.price
    if member:
        base = round(base * (1 - MEMBER_DISCOUNT), 2)  # ← intermediate rounding
    return round(base * (1 + BOOK_VAT), 2)

It looks harmless. But for The Odyssey (12.50 EUR), the correct member price is 12.50 × 0.95 × 1.04 = 12.35 EUR. With the intermediate rounding: 12.50 × 0.95 = 11.875 → round → 11.88 → × 1.04 = 12.3552 → 12.36 EUR. One cent too much. Ana doesn't notice: she tested mentally with Hamlet, where the double rounding happens to give the same result. A week later, Julia — a member — complains, and close_till() has been off by a few cents for seven days.

The lesson isn't "Ana is careless". It's that the cost of a bug grows brutally the later it is detected:

Moment of detection Typical cost In Papyrus
While you're writing the code Seconds Ana sees the test go red and fixes it before saving
Before integrating (test suite) Minutes pytest fails on her machine; nobody else suffers
In real use (production) Hours or days + trust Julia complains, seven till closes must be reviewed and cents refunded

A test that had put in writing "the member price of The Odyssey is 12.35" would have caught the bug in seconds. That is exactly what we're going to learn to write.

What a test is: the AAA pattern

A test is a fragment of code that runs another fragment of code with known inputs and checks that the result matches what is expected. Nearly every test in the world follows the same three-step structure, called the AAA pattern (Arrange, Act, Assert):

Step Name What you do Papyrus example
1. Arrange Arrange Set the stage: objects, data, files Create Book("The Odyssey", 12.50, 4)
2. Act Act Run the operation you want to test Call final_price(member=True)
3. Assert Assert Verify that the result is the expected one Did it return 12.35?

In code, with what you already know from the course:

from papyrus.models import Book

# 1. Arrange: a book with the catalog's canonical data
book = Book("The Odyssey", 12.50, 4)

# 2. Act: the operation under test
price = book.final_price(member=True)

# 3. Assert: assert fails with AssertionError if the condition is False
assert price == 12.35, f"expected 12.35, got {price}"

Notice three details:

  • Each test checks one thing. If this test fails, you know exactly what broke: the member price of The Odyssey.
  • The expected value is written out by hand (12.35), not computed with the same formula as the code under test. If you copied the formula, the test would carry the same bug as the code and would never fail.
  • The assert message (the f-string after the comma) tells you what happened without opening the debugger.

Bare assert: the first tool and its limits

You know the assert statement from Book's __post_init__ and from one-off validations: if the condition is false, it raises AssertionError. With that alone you can write a checking script:

# check_papyrus.py — our handcrafted "suite"
from papyrus.models import Book
from papyrus.warehouse import sell, find_book
from papyrus.errors import InsufficientStockError

catalog = {"Hamlet": Book("Hamlet", 9.95, 6)}

# Happy path: 2 Hamlets with VAT come to 20.70 EUR and the stock drops to 4
amount = sell(catalog, "Hamlet", 2)
assert amount == 20.70, f"wrong amount: {amount}"
assert catalog["Hamlet"].stock == 4, "the stock was not decremented"

# Expected error: asking for more stock than available must raise the exception
try:
    sell(catalog, "Hamlet", 99)
    assert False, "should have raised InsufficientStockError"
except InsufficientStockError:
    pass  # correct: the expected exception arrived

# Missing title: find_book returns None, doesn't blow up
assert find_book(catalog, "Moby-Dick") is None

print("All checks passed.")

This already is testing, and it's infinitely better than nothing. But it has serious limits:

Limit Consequence
It stops at the first failure If 5 things are broken, you only see 1; fix, run, see the next...
No report Nobody tells you "12 passed, 2 failed, here they are"; just a traceback or a final print
python -O strips out the asserts With the optimization flag, your "suite" checks nothing and doesn't warn you
State is shared The second block inherits the catalog mutated by the first: tests contaminate each other
Running is manual You have to remember to launch it — and to launch all of it
Checking exceptions is clumsy The try/except/assert False dance is error-prone

Testing frameworks (unittest in 09-02, pytest in 09-03) exist precisely to solve this list: they discover the tests, run them all, isolate them from one another and produce a readable report.

The handcrafted forerunner: if __name__ == "__main__"

In module 3, when we created papyrus_utils.py, we added an if __name__ == "__main__": block at the end of the file with a "mini-demo": a few example calls that only ran when the module was launched directly, not when imported. That was, without knowing it, the ancestor of a test: code that exercises the module with known data.

# At the end of papyrus_utils.py, back in module 3:
if __name__ == "__main__":
    # mini-demo / manual mini-test
    print(final_price(9.95, member=True))   # does it print 9.83?

The key difference: that demo printed, and a human judged with their eyes whether the result was correct. A test asserts, and it's the program itself that judges. That leap — from "look at the output" to "encode the expectation" — is the whole essence of testing. Everything else (frameworks, fixtures, TDD) is machinery around that idea.

Kinds of tests and the pyramid

Not all tests operate at the same level. The classic classification distinguishes three:

Kind What it checks Papyrus example Speed Recommended amount
Unit One small, isolated piece (a function, a method) final_price(member=True) returns 12.35 Milliseconds Many
Integration That several pieces collaborate well together close_till() reads a real test sales.csv and sums it correctly Tenths of a second Some
End-to-end (E2E) The complete system, as a person would use it Simulate the whole day: load catalog, sell, restock, close the till Seconds or more Few

The recommended proportions are drawn as a pyramid: a wide base of unit tests (fast and cheap), a middle layer of integration, and a thin tip of E2E (slow, fragile, expensive to maintain):

graph TD
    E2E["End-to-end: few<br/>(slow, fragile, expensive)"] --> INT["Integration: some<br/>(modules collaborating)"]
    INT --> UNIT["Unit: many<br/>(fast, isolated, cheap)"]

In this module we'll focus on the base of the pyramid — unit tests — with the odd foray into integration (testing close_till against a test CSV). You'll glimpse E2E testing of a web application when Papyrus reaches the web in module 10.

What to test first: happy paths, edges and expected errors

Given a function, which cases deserve a test? There are three families, in order of priority:

  1. The happy path: the function used as intended, with normal data.
  2. The edges: the boundaries where bugs nest — zero, one, exactly-the-maximum, empty.
  3. The expected errors: invalid inputs must fail as documented (the exceptions from module 7 are part of the contract!).

Let's apply it to Papyrus's two star functions. For Book.final_price:

Family Case Expected
Happy The Odyssey as a non-member 12.50 × 1.04 = 13.00
Happy The Odyssey as a member 12.35
Edge Rounding to 2 decimals Hamlet member → 9.83 (not 9.8306)
Error Book("X", -5.0) __post_init__ raises ValueError

And for sell(catalog, title, units), which has a richer contract (we armored it in module 7):

Family Case Expected
Happy Sell 2 Hamlets Returns 20.70 and the stock goes from 6 to 4
Edge Sell exactly the whole stock (4 Odysseys) Works; stock ends at 0
Error Sell 5 Odysseys (only 4 exist) InsufficientStockError and the catalog is left intact
Error Missing title BookNotFoundError
Error units=0 or negative ValueError
Error units="two" TypeError

Look at the InsufficientStockError row: the test doesn't just check that the exception fires, but that there were no side effects (the stock didn't change). Remember from 08-04 that sell mutates the catalog at the end precisely to guarantee this — and a test is how that guarantee survives a future refactor.

Qualities of a good test

Not just any code with assert is a good test. The good ones share four qualities:

  • Fast: the whole suite must run in seconds. If it drags, you'll stop running it, and a test that isn't run protects nothing.
  • Isolated: each test sets up its own scenario and doesn't depend on other tests or on execution order. The handcrafted script above violated this: the second block inherited the catalog mutated by the first.
  • Repeatable: same result today, tomorrow, and on Luis's laptop. No depending on the current time, the network or the real data/catalog.csv (in 09-03 you'll see tmp_path for this).
  • Readable: a test is executable documentation. test_sell_out_of_stock_raises_and_does_not_mutate_catalog tells the contract better than a paragraph of comments.

Common Mistakes and Tips

  • Testing only the happy path. Bugs live at the edges: stock 0, empty lists, missing titles. If your tests only cover the normal case, they protect little.
  • Computing the expected value with the same formula as the code under test. assert price == round(12.50 * 0.95 * 1.04, 2) would repeat the bug if the formula is wrong. Write the literal number: 12.35.
  • Comparing floats with == carelessly. It works here because final_price rounds to 2 decimals, but 0.1 + 0.2 == 0.3 is False (you saw it in M1). In 09-02 and 09-03 you'll meet assertAlmostEqual and pytest.approx.
  • Tests that depend on real data. If your test reads data/catalog.csv, it will fail the day Ana adds a book. Create the data inside the test.
  • Relying on assert for production validation. Remember: python -O strips them out. To validate user input, raise exceptions (ValueError); reserve assert for tests and internal invariants.
  • Tip: when you find a bug by hand, first write the test that reproduces it and then fix it. That regression test will keep the bug from coming back (we'll systematize this in 09-05).

Exercises

Exercise 1

Using bare assert and following the AAA pattern (comment each step), write three checks for Book.final_price: (a) Don Quixote as a non-member, (b) Don Quixote as a member, (c) Faust as a member. Work out the expected values by hand with the canonical constants (MEMBER_DISCOUNT=0.05, BOOK_VAT=0.04, rounding to 2 decimals).

Exercise 2

Without writing code: design in a table (family / case / expected result) the test cases for restock(catalog, title, units) -> None, following the model of the sell table. Include at least one happy path, one edge and two expected errors.

Exercise 3

Classify each scenario as a unit, integration or end-to-end test: (a) in_stock() returns False when stock == 0; (b) close_till() correctly sums a three-row sales.csv created for the test; (c) simulating a full Papyrus day: load the catalog from a file, sell 3 books, restock 2 and close the till checking the total; (d) InsufficientStockError stores title, requested and available in its attributes.

Solutions

Exercise 1

from papyrus.models import Book

# (a) Don Quixote, non-member: 15.90 × 1.04 = 16.536 → 16.54
book = Book("Don Quixote", 15.90, 8)       # Arrange
price = book.final_price()                  # Act
assert price == 16.54, f"expected 16.54, got {price}"  # Assert

# (b) Don Quixote, member: 15.90 × 0.95 × 1.04 = 15.7092 → 15.71
price = book.final_price(member=True)
assert price == 15.71, f"expected 15.71, got {price}"

# (c) Faust, member: 21.00 × 0.95 × 1.04 = 20.748 → 20.75
faust = Book("Faust", 21.00, 10)
price = faust.final_price(member=True)
assert price == 20.75, f"expected 20.75, got {price}"

Notice how each expected value is a hand-calculated literal, not the formula.

Exercise 2

Family Case Expected result
Happy Restock 5 Hamlets (stock 6) Returns None; stock becomes 11
Edge Restock 1 unit Stock 6 → 7 (the smallest valid amount works)
Error Missing title BookNotFoundError and catalog intact
Error units=0 or negative ValueError; stock unchanged
Error units=2.5 or "three" TypeError; stock unchanged

Exercise 3: (a) unit — one isolated method; (b) integration — the CSV reader, the parsing and the summing collaborate against a real (test) file; (c) end-to-end — it walks the whole system the way Ana would; (d) unit — it checks one exception class in isolation.

Conclusion

You now have the map: a test is code that puts in writing what the program must do — arrange, act, assert — and Ana's bug proved that catching an error in seconds costs infinitely less than catching it when Julia complains. You know that bare assert works but doesn't scale (it stops at the first failure, doesn't report, doesn't isolate), that the pyramid calls for many unit tests and few E2E, and that you must cover happy paths, edges and expected errors with tests that are fast, isolated, repeatable and readable. The if __name__ == "__main__" block from module 3 was our handcrafted forerunner; now it's time for the industrial tool. In the next lesson, the standard library gives us the first full framework: unittest, with test classes, specialized assertions (including one for the floats in our prices) and a runner that discovers and runs the whole suite with a single command.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved