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
- Organizing the project: the
tests/folder - Anatomy of a
TestCase - The assertions:
assertEqual,assertAlmostEqualand friends - Testing exceptions:
assertRaisesas a context manager setUpandtearDown: a fresh catalog per test- Running the suite:
python -m unittest - Papyrus's real suite:
test_models.pyandtest_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.pyTwo 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.py ↔ models.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 directlyBreaking 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. Iftest_non_member_price...fails,test_member_price...runs anyway — goodbye to bareassert's "stops at the first failure". self.assertEqual(a, b)replacesassert a == b, and not on a whim: when it fails, the report shows both values (13.0 != 12.99), something a nakedassertdoesn'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_vatreads like a sentence. When it fails six months from now, the name alone will tell you which contract broke. Run away fromtest_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 eachtest_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_stockThe 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 (-vlists the names). self.assertRaises(ValueError, sell(catalog, "Hamlet", 0)). This callssellbeforeassertRaisescan 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 insetUp): every test would mutate the same dictionary, resurrecting the aliasing problem from M4. assertTrue(a == b)instead ofassertEqual(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: launchpython -m unittestfrom the project root (where thepapyrus/folder lives), not from insidetests/.- 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
