The handcrafted script from the previous lesson did test Papyrus, but it stopped at the first failure, contaminated the catalog between checks, and had to be launched by hand. Python ships the solution out of the box: unittest, the standard library's testing framework (inspired by Java's classic JUnit). Without installing anything — you don't even need pip — you're going to turn those loose checks into an organized suite: test classes, specialized assertions, a fresh scenario for every test, and a runner that discovers and runs everything with one command.

Contents

  1. Organizing the project: the tests/ folder
  2. Anatomy of a TestCase
  3. The assertions: assertEqual, assertAlmostEqual and friends
  4. Testing exceptions: assertRaises as a context manager
  5. setUp and tearDown: a fresh catalog per test
  6. Running the suite: python -m unittest
  7. Papyrus's real suite: test_models.py and test_warehouse.py

Organizing the project: the tests/ folder

The universal convention is a tests/ folder next to the package, not inside it: production code shouldn't carry its own tests around. Our project ends up like this:

papyrus_project/
├── papyrus/               ← the package (modules 3-8)
│   ├── __init__.py
│   ├── errors.py
│   ├── models.py
│   ├── warehouse.py
│   └── register.py
├── data/
│   ├── catalog.csv
│   └── sales.csv
└── tests/                 ← NEW: the test suite
    ├── __init__.py        ← makes tests/ a package (helps discovery)
    ├── test_models.py     ← tests papyrus/models.py
    └── test_warehouse.py  ← tests papyrus/warehouse.py

Two conventions matter, because the runner uses them to discover the tests automatically: files start with test_, and inside them, so do the test methods. One test file per module under test (test_models.pymodels.py) keeps the symmetry easy to navigate.

Anatomy of a TestCase

In unittest, tests are grouped in classes that inherit from unittest.TestCase (inheritance, just as you saw it in M5). Every method whose name starts with test_ is an independent test:

# tests/test_models.py
import unittest

from papyrus.models import Book


class TestFinalPrice(unittest.TestCase):
    """Tests for the price calculation with VAT and member discount."""

    def test_non_member_price_applies_only_vat(self):
        # Arrange
        book = Book("The Odyssey", 12.50, 4)
        # Act
        price = book.final_price()
        # Assert: 12.50 × 1.04 = 13.00
        self.assertEqual(price, 13.00)

    def test_member_price_applies_discount_and_vat(self):
        book = Book("The Odyssey", 12.50, 4)
        price = book.final_price(member=True)
        # 12.50 × 0.95 × 1.04 = 12.35 — Ana's bug would die right here
        self.assertAlmostEqual(price, 12.35, places=2)


if __name__ == "__main__":
    unittest.main()  # lets you run this file directly

Breaking it down:

  • The class groups related tests; you can have several per file (TestFinalPrice, TestBookValidation...).
  • Each test_ method is an isolated test: the runner executes them separately and reports each one. If test_non_member_price... fails, test_member_price... runs anyway — goodbye to bare assert's "stops at the first failure".
  • self.assertEqual(a, b) replaces assert a == b, and not on a whim: when it fails, the report shows both values (13.0 != 12.99), something a naked assert doesn't do.
  • The final if __name__ == "__main__": unittest.main() is our old friend from M3, promoted: it no longer launches a demo you have to eyeball, but a suite that judges itself.
  • Note the descriptive names: test_member_price_applies_discount_and_vat reads like a sentence. When it fails six months from now, the name alone will tell you which contract broke. Run away from test_1, test_price2.

The assertions: assertEqual, assertAlmostEqual and friends

TestCase offers dozens of assert* methods. These are the ones you'll use 95% of the time:

Method Checks Papyrus example
assertEqual(a, b) a == b assertEqual(amount, 20.70)
assertNotEqual(a, b) a != b Member price ≠ regular price
assertAlmostEqual(a, b, places=2) Float equality with tolerance Any computed price
assertTrue(x) / assertFalse(x) Truthiness assertTrue(book.in_stock())
assertIn(x, col) x in col assertIn("Hamlet", catalog)
assertIsNone(x) / assertIsNotNone(x) x is None find_book with a missing title
assertIs(a, b) Identity (a is b) Checking it returns the very same object
assertRaises(Exc) That the exception is raised InsufficientStockError
assertGreater(a, b) / assertLess(a, b) Comparisons Stock after restocking

Two deserve a comment:

assertAlmostEqual and floats. Since M1 you know that 0.1 + 0.2 == 0.3 is False: binary floats don't represent decimals exactly. Our prices are rounded to 2 decimals inside final_price, so assertEqual tends to work... until a refactor moves where the rounding happens and the test fails with 12.350000000000001. assertAlmostEqual(price, 12.35, places=2) compares by rounding the difference to 2 decimals, and it's the robust choice for every monetary amount in tests.

assertEqual between objects. Remember from M5 that @dataclass generates an __eq__ that compares field by field. That makes checking whole objects trivial:

self.assertEqual(
    find_book(catalog, "Hamlet"),
    Book("Hamlet", 9.95, 6),   # generated __eq__: compares title, price and stock
)

Without the dataclass you'd have to compare attribute by attribute. It's one of those dividends M5 pays us now.

Testing exceptions: assertRaises as a context manager

In module 7 we armored sell with exceptions; they are part of the contract and deserve tests. The modern form of assertRaises is as a context manager — the same __enter__/__exit__ protocol you built by hand in 08-04:

from papyrus.errors import InsufficientStockError

def test_sell_out_of_stock_raises_error(self):
    catalog = {"The Odyssey": Book("The Odyssey", 12.50, 4)}

    with self.assertRaises(InsufficientStockError) as ctx:
        sell(catalog, "The Odyssey", 5)   # only 4 exist

    # The captured exception lives in ctx.exception: we can inspect it
    self.assertEqual(ctx.exception.requested, 5)
    self.assertEqual(ctx.exception.available, 4)

See the elegance? The context manager's __exit__ receives the exception (as you learned in 08-04) and decides: if it's the expected one, it swallows it and the test goes on; if none fires, the test fails with "InsufficientStockError not raised". No more of the previous lesson's try/except/assert False dance. And since InsufficientStockError stores title, requested and available (M7), we can verify that the exception carries the right information, not just that it fires.

setUp and tearDown: a fresh catalog per test

The handcrafted script had a vice: the tests shared the catalog, and order mattered (M4 taught you how treacherous shared mutations are). unittest solves it with two special methods:

  • setUp(self): runs before each test_ method. Ideal for setting the stage.
  • tearDown(self): runs after each test, even if it failed. Ideal for cleanup (closing files, deleting temporaries).
class TestSell(unittest.TestCase):

    def setUp(self):
        """Canonical catalog FRESHLY BUILT before each test."""
        self.catalog = {
            "The Odyssey": Book("The Odyssey", 12.50, 4),
            "Hamlet": Book("Hamlet", 9.95, 6),
            "Don Quixote": Book("Don Quixote", 15.90, 8),
            "Faust": Book("Faust", 21.00, 10),
        }

    def test_sell_decrements_stock(self):
        sell(self.catalog, "Hamlet", 2)
        self.assertEqual(self.catalog["Hamlet"].stock, 4)

    def test_sell_returns_amount_with_vat(self):
        amount = sell(self.catalog, "Hamlet", 2)
        # This test gets ANOTHER fresh catalog: the stock is back to 6
        self.assertAlmostEqual(amount, 20.70, places=2)

Even though test_sell_decrements_stock leaves the stock at 4, the next test starts from 6: setUp runs again. Each test is an island — the "isolated" quality from 09-01, for free. (There are also setUpClass/tearDownClass, run once per class, for expensive resources; with our in-memory catalog we don't need them.)

Running the suite: python -m unittest

From the project root (with the M1 venv activated):

# Discovers and runs EVERYTHING starting with test_ under tests/
python -m unittest discover tests

# Verbose mode: lists each test with its result
python -m unittest discover tests -v

# Just one file, one class or one specific test
python -m unittest tests.test_warehouse
python -m unittest tests.test_warehouse.TestSell
python -m unittest tests.test_warehouse.TestSell.test_sell_decrements_stock

The output tells the whole story: a dot per passing test, F for a failure (an assertion didn't hold), E for an error (the test blew up with an unexpected exception), and the summary at the end:

...F..
======================================================================
FAIL: test_member_price_applies_discount_and_vat (tests.test_models.TestFinalPrice)
----------------------------------------------------------------------
AssertionError: 12.36 != 12.35 within 2 places (0.01 difference)
----------------------------------------------------------------------
Ran 6 tests in 0.004s

FAILED (failures=1, errors=0)

There's Ana's bug, caught in 4 milliseconds, with both values in plain sight. This is what bare assert couldn't give you.

Papyrus's real suite: test_models.py and test_warehouse.py

Let's put it all together. First the models — the happy path, the __post_init__ validation and in_stock:

# tests/test_models.py
import unittest

from papyrus.models import Book


class TestBookValidation(unittest.TestCase):

    def test_valid_book_is_created_with_its_data(self):
        book = Book("Faust", 21.00, 10)
        self.assertEqual(book, Book("Faust", 21.00, 10))  # dataclass __eq__

    def test_default_stock_is_zero(self):
        self.assertEqual(Book("Faust", 21.00).stock, 0)

    def test_negative_price_raises_valueerror(self):
        with self.assertRaises(ValueError):
            Book("Faust", -21.00, 10)   # __post_init__ vetoes it (M5)

    def test_in_stock_is_false_at_zero(self):
        self.assertFalse(Book("Faust", 21.00, 0).in_stock())
        self.assertTrue(Book("Faust", 21.00, 1).in_stock())

And the warehouse — happy path, edges and the three errors in sell's contract:

# tests/test_warehouse.py
import unittest

from papyrus.warehouse import find_book, get_book, sell
from papyrus.errors import BookNotFoundError, InsufficientStockError
from papyrus.models import Book


class TestSell(unittest.TestCase):

    def setUp(self):
        self.catalog = {
            "The Odyssey": Book("The Odyssey", 12.50, 4),
            "Hamlet": Book("Hamlet", 9.95, 6),
        }

    def test_happy_sale_returns_amount_and_decrements(self):
        amount = sell(self.catalog, "Hamlet", 2)
        self.assertAlmostEqual(amount, 20.70, places=2)
        self.assertEqual(self.catalog["Hamlet"].stock, 4)

    def test_selling_entire_stock_leaves_zero(self):
        sell(self.catalog, "The Odyssey", 4)           # exact edge
        self.assertEqual(self.catalog["The Odyssey"].stock, 0)

    def test_out_of_stock_raises_and_does_not_mutate(self):
        with self.assertRaises(InsufficientStockError):
            sell(self.catalog, "The Odyssey", 5)
        self.assertEqual(self.catalog["The Odyssey"].stock, 4)  # intact!

    def test_missing_title_raises_book_not_found(self):
        with self.assertRaises(BookNotFoundError):
            sell(self.catalog, "Moby-Dick", 1)

    def test_invalid_units(self):
        with self.assertRaises(ValueError):
            sell(self.catalog, "Hamlet", 0)
        with self.assertRaises(TypeError):
            sell(self.catalog, "Hamlet", "two")


class TestLookup(unittest.TestCase):

    def setUp(self):
        self.catalog = {"Hamlet": Book("Hamlet", 9.95, 6)}

    def test_find_missing_returns_none(self):
        self.assertIsNone(find_book(self.catalog, "Moby-Dick"))

    def test_get_missing_raises_error(self):
        with self.assertRaises(BookNotFoundError):
            get_book(self.catalog, "Moby-Dick")

Nine tests that put in writing the full contract we built across modules 5 to 8: the signatures mypy verifies for types, now verified for behavior. If Ana touches sell tomorrow, python -m unittest discover tests will tell her in milliseconds whether she broke anything.

Common Mistakes and Tips

  • Forgetting the test_ prefix on a method. The runner silently ignores it: the suite passes... because that test doesn't exist. If a test "never fails", first check that it's actually running (-v lists the names).
  • self.assertRaises(ValueError, sell(catalog, "Hamlet", 0)). This calls sell before assertRaises can stand guard: the exception blows the test up as an error. Use the context manager form (with self.assertRaises(...):) and make the call inside the block.
  • Sharing mutable state at class level (catalog = {...} as a class attribute instead of building it in setUp): every test would mutate the same dictionary, resurrecting the aliasing problem from M4.
  • assertTrue(a == b) instead of assertEqual(a, b). It works, but on failure it only says "False is not true" instead of showing both values. Always use the most specific assertion available.
  • ModuleNotFoundError: No module named 'papyrus' when running the tests: launch python -m unittest from the project root (where the papyrus/ folder lives), not from inside tests/.
  • Tip: name every test as an assertion of the contract (test_out_of_stock_raises_and_does_not_mutate). The failure report becomes a list of broken promises, readable without opening any code.

Exercises

Exercise 1

Write TestRestock in tests/test_warehouse.py, with its own setUp, covering the table you designed in exercise 2 of 09-01: (a) restocking 5 Hamlets raises the stock from 6 to 11 and the function returns None; (b) restocking a missing title raises BookNotFoundError; (c) units=0 raises ValueError and the stock doesn't change.

Exercise 2

Add to TestFinalPrice a hand-rolled parametrized test, test_canonical_member_prices, that checks with assertAlmostEqual the four member prices of the canonical catalog (12.35, 9.83, 15.71, 20.75) using a loop over a list of (title, base_price, expected) tuples. Hint: use with self.subTest(title=title): inside the loop so that if two prices fail, the report shows both.

Exercise 3

Write a test proving that InsufficientStockError carries the right information: when selling 9 Hamlets (stock 6), the captured exception must have title == "Hamlet", requested == 9 and available == 6.

Solutions

Exercise 1

class TestRestock(unittest.TestCase):

    def setUp(self):
        self.catalog = {"Hamlet": Book("Hamlet", 9.95, 6)}

    def test_restock_adds_stock_and_returns_none(self):
        result = restock(self.catalog, "Hamlet", 5)
        self.assertIsNone(result)                      # -> None, as its signature says
        self.assertEqual(self.catalog["Hamlet"].stock, 11)

    def test_restock_missing_raises_error(self):
        with self.assertRaises(BookNotFoundError):
            restock(self.catalog, "Moby-Dick", 5)

    def test_restock_zero_units_raises_valueerror_and_does_not_mutate(self):
        with self.assertRaises(ValueError):
            restock(self.catalog, "Hamlet", 0)
        self.assertEqual(self.catalog["Hamlet"].stock, 6)

Exercise 2

def test_canonical_member_prices(self):
    cases = [
        ("The Odyssey", 12.50, 12.35),
        ("Hamlet", 9.95, 9.83),
        ("Don Quixote", 15.90, 15.71),
        ("Faust", 21.00, 20.75),
    ]
    for title, base, expected in cases:
        with self.subTest(title=title):
            book = Book(title, base, 1)
            self.assertAlmostEqual(book.final_price(member=True), expected, places=2)

subTest makes each loop iteration report separately: without it, the first failing price would hide the rest. (Sneak preview: in the next lesson, pytest.mark.parametrize does the same thing more elegantly.)

Exercise 3

def test_insufficient_stock_carries_the_data(self):
    catalog = {"Hamlet": Book("Hamlet", 9.95, 6)}
    with self.assertRaises(InsufficientStockError) as ctx:
        sell(catalog, "Hamlet", 9)
    self.assertEqual(ctx.exception.title, "Hamlet")
    self.assertEqual(ctx.exception.requested, 9)
    self.assertEqual(ctx.exception.available, 6)

Conclusion

Papyrus now has its first real suite: tests/test_models.py and tests/test_warehouse.py pin down the contract of Book, sell, restock and the lookups — happy paths, edges and the M7 exceptions included — with a fresh catalog per test thanks to setUp, floats compared with assertAlmostEqual and exceptions guarded by the assertRaises context manager. All with the standard library and one command: python -m unittest discover tests. But you may have noticed a certain amount of ceremony: inheriting from TestCase, memorizing twenty assert* methods, the ever-present self.. The Python community thought the same, which is why the dominant tool in the real world is a different one: pytest, where a test is a plain function and the assertion is the everyday assert — but with superpowers. It installs with pip in the venv, exactly as you learned in module 1. That's the next lesson.

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