Papyrus Online works — or so it seems when you try it by hand. But "I tried it by hand on Tuesday" is not verification: it is an anecdote. This lesson turns the 12-01 acceptance criteria into an executable test suite, the one that lets you touch any piece of the system and know in twenty seconds whether you broke something. We will apply the M9 pyramid to the complete project — what gets tested at each level and with which tool —, write the integration test that walks the whole flow (sell → persist → reload → reconcile the till), test the interface with its test client, and learn to debug across the layers when a test says no. At the end: the quality checklist that decides whether the project is ready to ship.
Contents
- The pyramid applied to Papyrus Online
- Test data vs real data: the canonical fixtures
- The minimum required suite (criteria → tests)
- The full-flow integration test
- Testing the interface: Flask's test client / Django's Client
- Regressions: every bug leaves its test
- Debugging the integrated system
- Final quality checklist
The pyramid applied to Papyrus Online
In 09-01 the pyramid was theory; now it has names attached. Each level tests a different thing, with a different tool, and in the right proportion (many at the bottom, few at the top):
| Level | What is tested HERE | Tool | How many | Speed |
|---|---|---|---|---|
| Unit (domain) | final_price, apply_coupon, in_stock, building Book/Member |
pure pytest, no disk or network | 10-15 | milliseconds |
| Service | The full SalesService: the three phases, every error, every member/coupon combination |
pytest + repositories over tmp_path |
8-12 | fast |
| Integration | The whole flow: sell → persist → reload → the till close reconciles | pytest + tmp_path |
2-3 | moderate |
| Interface | Every route returns its status code and its JSON/HTML | test client (Flask) / Client (Django) |
5-8 | moderate |
The distribution rule: if a behavior can be tested at a lower level, it is tested there. The 12.35 rounding is verified at the unit level, not by firing HTTP requests; the endpoint only verifies that it translates correctly (right JSON, right code), because the substance is already tested further down. Testing the rounding over HTTP works, but it is slow, brittle and, when it fails, it doesn't tell you where the bug is.
Test data vs real data
An absolute rule: tests never touch data/. A test that writes to your real catalog is a bomb: it corrupts your data or, worse, passes or fails depending on what you sold yesterday. All tests work over tmp_path (M9) with the canonical fixtures — the four books of always, precisely because you have known their numbers by heart for eleven modules:
# tests/conftest.py
import json
import pytest
from papyrus.repositories import (CatalogRepository, MembersRepository,
SalesLog)
from papyrus.services import SalesService
CATALOG = [
{"title": "The Odyssey", "author": "Homer", "price": 12.50, "stock": 4},
{"title": "Hamlet", "author": "Shakespeare", "price": 9.95, "stock": 6},
{"title": "Don Quixote", "author": "Cervantes", "price": 15.90, "stock": 8},
{"title": "Faust", "author": "Goethe", "price": 21.00, "stock": 10},
]
MEMBERS = [{"code": "LUIS-001", "name": "Luis", "joined": "2025-03-12"},
{"code": "MARTA-002", "name": "Marta", "joined": "2025-06-30"},
{"code": "PAU-003", "name": "Pau", "joined": "2026-01-15"}]
@pytest.fixture
def data_dir(tmp_path):
"""A fake data/, freshly created for EACH test (isolation)."""
(tmp_path / "catalog.json").write_text(
json.dumps(CATALOG, ensure_ascii=False), encoding="utf-8")
(tmp_path / "members.json").write_text(
json.dumps(MEMBERS, ensure_ascii=False), encoding="utf-8")
return tmp_path
@pytest.fixture
def service(data_dir):
"""A SalesService wired onto the fake data/."""
return SalesService(CatalogRepository(data_dir / "catalog.json"),
MembersRepository(data_dir / "members.json"),
SalesLog(data_dir / "sales.csv"))Here the 12-02 design decision pays off: because the service receives the repositories already built, the tests inject tmp_path repositories without touching a line of production code. If your service had opened "data/catalog.json" directly, this lesson would be impossible. Code that is easy to test and code that is well designed are the same code.
The minimum required suite
The 12-01 acceptance criteria, one by one, turned into named tests. This table is your NFR1: the suite is complete when every row exists and passes:
| Test | Requirement it verifies |
|---|---|
test_final_price_member_and_non_member (parametrized: the 8 canonical amounts) |
FR2 |
test_adding_duplicate_book_fails |
FR1 |
test_delete_book_then_lookup_raises_not_found |
FR1 |
test_sell_to_valid_member_applies_discount (12.35 with LUIS-001) |
FR2, FR3 |
test_sell_with_unknown_member_raises_invalid_member |
FR2 |
test_sell_without_stock_raises_and_changes_nothing |
FR3 |
test_sell_with_valid_and_invalid_coupon (18.67 / InvalidCouponError) |
FR3 |
test_sale_persists_and_reloads (the integration one, below) |
FR4 |
test_save_is_atomic_no_corrupt_json |
FR4 |
test_api_routes_return_correct_codes (or Django views) |
FR5 |
test_report_reproduces_canonical_numbers (520 u, Saturday 130) |
FR6 |
test_rejected_sale_leaves_warning_in_log (with caplog) |
FR7 |
With the parametrizations, that adds up to about 25 tests. It is not a magic number: it is the literal translation of the 12-01 contract. If tomorrow you add a requirement, its row appears here before its code — that was TDD (09-03), and this project is the best place to practice it.
The full-flow integration test
The most valuable test in the project: it walks the four data layers (service → disk → reload → aggregate) and checks that the whole story adds up. Complete and commented:
# tests/test_integration.py
from datetime import date
from papyrus.repositories import (CatalogRepository, MembersRepository,
SalesLog)
from papyrus.services import SalesService
def test_full_flow_sell_persist_reload_reconcile(data_dir):
# --- 1. SELL: member + coupon, two sales ---
service = SalesService(CatalogRepository(data_dir / "catalog.json"),
MembersRepository(data_dir / "members.json"),
SalesLog(data_dir / "sales.csv"))
s1 = service.sell("Faust", 1, member_code="LUIS-001", coupon="PAPYRUS10")
s2 = service.sell("Hamlet", 1) # Julia, not a member
assert s1.amount == 18.67 # 21.00 ×1.04 ×0.95 ×0.90
assert s2.amount == 10.35 # 9.95 ×1.04
# --- 2. PERSIST and RELOAD: a NEW instance that only sees the disk ---
# If the stock is right here, it is because save() and load() truly
# work, not because the old object remembered it in memory.
reloaded_catalog = CatalogRepository(data_dir / "catalog.json").load()
assert reloaded_catalog["Faust"].stock == 9 # was 10
assert reloaded_catalog["Hamlet"].stock == 5 # was 6
# --- 3. RECONCILE: the till close comes from the CSV, not from memory ---
new_service = SalesService(CatalogRepository(data_dir / "catalog.json"),
MembersRepository(data_dir / "members.json"),
SalesLog(data_dir / "sales.csv"))
today = date.today().isoformat()
assert new_service.close_till(today) == 29.02 # 18.67 + 10.35The detail that makes it a true integration test is in step 2: the new instance. If you reuse the original service, the test can pass even with a broken save(), because the object remembers the stock in memory. Reloading from disk is what proves FR4.
Testing the interface
Track A — Flask's test client (10-03). To inject the tmp_path you will need the app factory pattern: a create_app(service) function instead of wiring up a global service at import time. It is a ten-minute refactor that proves the rule once more: testing improves the design.
# tests/test_app.py
import pytest
from app import create_app
@pytest.fixture
def client(service): # reuses the conftest fixture
app = create_app(service)
app.config["TESTING"] = True
return app.test_client()
def test_sale_without_stock_returns_409(client):
response = client.post("/api/sales",
json={"title": "The Odyssey", "units": 99})
assert response.status_code == 409
assert "error" in response.get_json() # the body explains the rejection
def test_valid_sale_returns_201_with_amount(client):
response = client.post("/api/sales",
json={"title": "Faust", "units": 1,
"member": "LUIS-001", "coupon": "PAPYRUS10"})
assert response.status_code == 201
assert response.get_json()["amount"] == 18.67Track B — Django's Client (10-05), where TestCase gives you a clean database per test (its equivalent of tmp_path):
# catalog/tests.py
from django.test import TestCase
from catalog.models import Book
class TestSale(TestCase):
def setUp(self):
Book.objects.create(title="The Odyssey", author="Homer",
price=12.50, stock=4)
def test_sale_without_stock_shows_error_in_form(self):
response = self.client.post("/sell/",
{"title": "The Odyssey", "units": 99})
self.assertEqual(response.status_code, 200) # the form is re-shown
self.assertContains(response, "stock") # …with the error visibleNotice what these tests verify: the translation, not the business. That the 409 is a 409, that the JSON carries the amount, that the form re-shows the error. The 18.67 calculation was already proven two levels further down.
Regressions: every bug leaves its test
During 12-03 you found bugs (we all do). The 09-05 rule, now as project discipline: no bug is closed without its test. The flow: bug detected → you write the test that reproduces it (it fails, red) → you fix → the test passes (green) → the test stays in the suite forever. That test is worth more than ten invented ones: it guards a spot where your system proved it knew how to break. Note every regression in DECISIONS.md with one line ("close_till was adding strings — test_close_till_converts_amounts"): in 12-05, that list will be gold for your retrospective.
Debugging the integrated system
When a test (or real use) fails in the complete system, the bug can be in any of the four layers. Three techniques, in order:
- Read the traceback ACROSS the layers. A traceback from the integrated system is long: Flask/Django lines, your lines in
app.py, inservices.py, inrepositories.py. Walk it bottom-up and locate the last line that is yours: that is the layer where it blew up. But careful: where it blows up is not always where it broke — aKeyErrorin the service can be a JSON badly saved by the repository three sales ago. The traceback gives you the layer of the explosion; the cause may live one layer further down. - The log as a witness between layers.
papyrus.logrecords what each layer did and in what order (FR7 is no longer a bureaucratic requirement: it is your black box). The endpoint returned a 500? Check the log: if the last line is a sale'sINFO, the failure came after selling — in the save or in serializing the response. You just ruled out two layers without opening the debugger. pdbat the integration point.breakpoint()right where one layer hands over to the next (the endpoint's call to the service, the service's call to the repository) and inspect the package crossing the border:p payload,p type(row["units"]). As 12-03 said: most integration bugs are "I thought you were passing me X and it was Y" — and the border is where you see it.
Final quality checklist
Before declaring the project "shippable", run it through this list. It is not bureaucracy: it is the difference between believing it is right and knowing it.
- [ ] FR1: duplicate creation rejected; delete + lookup → not found. Test green.
- [ ] FR2: the 8 canonical amounts exact (parametrized); fake member rejected.
- [ ] FR3: a sale without stock changes nothing (check the stock after the error!); 18.67 with member+coupon; fake coupon rejected.
- [ ] FR4: the integration test passes; killing the process mid-save does not corrupt the JSON.
- [ ] FR5: every route/view with its status-code test.
- [ ] FR6: report over
sales_2026.csv→ 520 units, Saturday 130, PNG generated. - [ ] FR7:
caplog(or the file) confirms theWARNINGon every rejection. - [ ] NFR1:
pytest→ all green, ~25 tests, none touching the realdata/. - [ ] NFR2: zero public package functions without type hints.
- [ ] NFR3: zero generic
except Exceptionwithout a written justification. - [ ] NFR4: the README exists and someone else could start the project with it (verified in 12-05).
Common Mistakes and Tips
- Testing the business over HTTP. If
test_api_...verifies roundings, you have the pyramid upside down: slow, brittle and with poor diagnostics. The endpoint tests the translation; the domain tests the substance. - Tests that share state. The classic symptom: they pass alone, they fail in the suite (or depending on the order). Cause: they write to the same place. The
data_dirfixture creates a fresh world per test; always use it. - Comparing floats recklessly.
assert amount == 18.67works here because we round to 2 decimals at a single point (12-03). If an amount assert fails by 0.0000001, don't "fix the test withpytest.approx": you have one rounding too many or too few in the code.approxis for continuous mathematics, not for rounded money. - Deleting the test of a fixed bug ("it doesn't fail anymore, it's redundant"). It is exactly the other way around: it is the only test with a proven real failure in its history.
- Debugging by sprinkling
printeverywhere. You already have three better, ordered tools: traceback (which layer?), log (what happened before?), pdb (what crosses the border?). Strayprints end up forgotten in the code — the log is aprintwith a contract.
Exercises
- Project milestone — the complete suite. Implement the 12 rows of the minimum suite (with their parametrizations). Verification:
pytest -vall green, and each test's name says which FR it covers without opening the file. - Project milestone — the atomic-save test. Write
test_save_is_atomic: trigger a failure midway throughsave(hint: pass a catalog containing a non-serializable object, which will makejson.dumpblow up) and check that the original file remains intact and loadable. This test justifies the wholeos.replacedesign from 12-03. - Project milestone — your regression. Pick the most painful bug you found in 12-03, write its regression test with a name that tells the story, and add the matching line to
DECISIONS.md.
Solutions
- A self-check instead of a solution: run
pytest --collect-onlyand compare the list with the minimum-suite table; each row must have at least one collected test. Iftest_report_...takes a few seconds, that is fine: it is the price of pandas + PNG, and that is why there is one, not twenty. - Reference:
broken_catalog = dict(catalog); broken_catalog["X"] = object()andwith pytest.raises(TypeError): repo.save(broken_catalog); afterwards,repo.load()must return the original catalog without error. If yoursavewas writing directly ontoself._path, this test will tell you so with aJSONDecodeError— and you have just understood, through a controlled failure, what the temporary file existed for. - No single solution. Quality criterion for the name: someone who never lived the bug must understand what it guards —
test_close_till_converts_amounts_to_floatyes;test_fixed_bug_2no.
Conclusion
Papyrus Online is no longer code that seems to work: it is a verified system. The pyramid stopped being a drawing — a domain with millisecond unit tests, a service over tmp_path, an integration test that walks sell → persist → reload → reconcile, and an interface whose HTTP translation is proven with its test client. The 12-01 criteria are now 25 named tests, every development bug left its regression behind, and you know how to debug across the layers with the traceback, the log and pdb at the borders. The final checklist is ticked: the project is finished. But finished is not the same as delivered: someone else still needs to be able to install it, understand it and see it work — and you still need to be able to tell its story. Documentation, a demo, the final report and a look back over the whole road: that is the last lesson of the course.
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
