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

  1. The pyramid applied to Papyrus Online
  2. Test data vs real data: the canonical fixtures
  3. The minimum required suite (criteria → tests)
  4. The full-flow integration test
  5. Testing the interface: Flask's test client / Django's Client
  6. Regressions: every bug leaves its test
  7. Debugging the integrated system
  8. 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.35

The 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.67

Track 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 visible

Notice 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, in services.py, in repositories.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 — a KeyError in 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.log records 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's INFO, the failure came after selling — in the save or in serializing the response. You just ruled out two layers without opening the debugger.
  • pdb at 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 the WARNING on every rejection.
  • [ ] NFR1: pytest → all green, ~25 tests, none touching the real data/.
  • [ ] NFR2: zero public package functions without type hints.
  • [ ] NFR3: zero generic except Exception without 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_dir fixture creates a fresh world per test; always use it.
  • Comparing floats recklessly. assert amount == 18.67 works 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 with pytest.approx": you have one rounding too many or too few in the code. approx is 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 print everywhere. You already have three better, ordered tools: traceback (which layer?), log (what happened before?), pdb (what crosses the border?). Stray prints end up forgotten in the code — the log is a print with a contract.

Exercises

  1. Project milestone — the complete suite. Implement the 12 rows of the minimum suite (with their parametrizations). Verification: pytest -v all green, and each test's name says which FR it covers without opening the file.
  2. Project milestone — the atomic-save test. Write test_save_is_atomic: trigger a failure midway through save (hint: pass a catalog containing a non-serializable object, which will make json.dump blow up) and check that the original file remains intact and loadable. This test justifies the whole os.replace design from 12-03.
  3. 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

  1. A self-check instead of a solution: run pytest --collect-only and compare the list with the minimum-suite table; each row must have at least one collected test. If test_report_... takes a few seconds, that is fine: it is the price of pandas + PNG, and that is why there is one, not twenty.
  2. Reference: broken_catalog = dict(catalog); broken_catalog["X"] = object() and with pytest.raises(TypeError): repo.save(broken_catalog); afterwards, repo.load() must return the original catalog without error. If your save was writing directly onto self._path, this test will tell you so with a JSONDecodeError — and you have just understood, through a controlled failure, what the temporary file existed for.
  3. 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_float yes; test_fixed_bug_2 no.

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

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