unittest does the job, but it drags ceremony along: inheriting from TestCase, the ever-present self., twenty assert* methods to memorize. Years ago the Python community converged on an alternative that boils a test down to its essence — a function and an assert — without losing any power: pytest. In this lesson you'll rewrite the Papyrus suite in pytest style, discover fixtures (direct relatives of your generators from 08-03), test file handling without touching the real data thanks to tmp_path, and parametrize the four canonical member prices in three lines.

Contents

  1. Installing in the venv and the first test
  2. Naked assert with superpowers: introspection
  3. The same suite, rewritten: unittest vs pytest
  4. Exceptions with pytest.raises and floats with pytest.approx
  5. Fixtures: setUp turned into a function (and with yield)
  6. tmp_path: testing files without touching data/
  7. Parametrization with @pytest.mark.parametrize
  8. Running: pytest -v, -k and friends
  9. The next rung of maturity: unittest.mock (just the map)

Installing in the venv and the first test

pytest isn't in the standard library: you install it with pip inside the project's venv, exactly as you learned in module 1 (and it's worth freezing it in your dependencies):

# With the venv activated, at the project root
pip install pytest
pytest --version    # quick check

A pytest test is a plain function whose name starts with test_, in a test_*.py file. No classes, no inheritance, no self:

# tests/test_models.py — pytest version
from papyrus.models import Book


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

Run pytest at the project root and it discovers tests/, the test_*.py files and the test_* functions on its own (it also understands TestCase classes, so your 09-02 suite runs unchanged under pytest — the migration can be gradual).

Naked assert with superpowers: introspection

In 09-01 we said bare assert gave poor messages, and in 09-02 that assertEqual existed to show you both values. pytest dissolves the dilemma: it rewrites the asserts in your tests (assertion introspection) so that, on failure, they show every evaluated subexpression. If Ana's double-rounding bug came back:

    def test_member_price_applies_discount_and_vat():
        book = Book("The Odyssey", 12.50, 4)
>       assert book.final_price(member=True) == 12.35
E       assert 12.36 == 12.35
E        +  where 12.36 = final_price(member=True)
E        +  where final_price = Book(title='The Odyssey', price=12.5, stock=4).final_price

You get the actual value (12.36), the expected one (12.35) and even the __repr__ of the Book involved (courtesy of the M5 dataclass) — without having written a single message. That's why in pytest the naked assert is no limitation: it's the interface.

The same suite, rewritten: unittest vs pytest

The previous lesson's TestSell class, translated:

# tests/test_warehouse.py — pytest version
import pytest

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


@pytest.fixture
def catalog():
    """Fresh canonical catalog for every test that asks for it."""
    return {
        "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_happy_sale_returns_amount_and_decrements(catalog):
    amount = sell(catalog, "Hamlet", 2)
    assert amount == pytest.approx(20.70)
    assert catalog["Hamlet"].stock == 4


def test_out_of_stock_raises_and_does_not_mutate(catalog):
    with pytest.raises(InsufficientStockError):
        sell(catalog, "The Odyssey", 5)
    assert catalog["The Odyssey"].stock == 4


def test_find_missing_returns_none(catalog):
    assert find_book(catalog, "Moby-Dick") is None

The comparison, face to face:

Aspect unittest pytest
Origin Standard library (no install) pip install pytest in the venv
Test structure Class inheriting TestCase + method Plain function
Equality assertion self.assertEqual(a, b) assert a == b (with introspection)
Floats self.assertAlmostEqual(a, b, places=2) assert a == pytest.approx(b)
Exceptions with self.assertRaises(Exc): with pytest.raises(Exc, match="..."):
Per-test scenario setUp/tearDown (methods) Fixtures (functions, injected by name)
Parametrizing Loop + subTest @pytest.mark.parametrize
Temporary files Roll your own (tempfile) tmp_path fixture built in
Running python -m unittest discover tests pytest
Ecosystem Stable, minimal Huge (plugins: coverage, parallelism...)

Why does the community prefer pytest? Less noise per test (what matters takes up all the space), far superior failure messages, composable fixtures and a giant plugin ecosystem. unittest remains valuable — dependency-free, ubiquitous in older corporate code — and everything you learned in 09-02 (AAA, isolation, descriptive names) carries over intact: only the syntax changes.

Exceptions with pytest.raises and floats with pytest.approx

pytest.raises is, once again, a context manager (08-04 keeps paying off). Its extra over assertRaises is the match= parameter, a regular expression that must be found in the exception's message:

def test_out_of_stock_explains_the_problem(catalog):
    with pytest.raises(InsufficientStockError, match="The Odyssey") as exc_info:
        sell(catalog, "The Odyssey", 5)
    # exc_info.value is the exception: we verify its payload (M7)
    assert exc_info.value.requested == 5
    assert exc_info.value.available == 4

match="The Odyssey" guarantees the message mentions the title — we test not only that it fails, but that it fails explaining itself well, which was the whole point of our PapyrusError hierarchy.

pytest.approx solves floats with a syntax that reads like mathematics:

assert 0.1 + 0.2 == pytest.approx(0.3)                 # True, relative tolerance by default
assert amount == pytest.approx(20.70, abs=0.01)        # absolute tolerance of 1 cent

For monetary amounts, abs=0.01 (within a cent) is an explicit, clear choice.

Fixtures: setUp turned into a function (and with yield)

The catalog fixture above deserves a second look. It's declared with @pytest.fixture (a decorator — 08-02!) and tests receive it by asking for it as a parameter: pytest sees that the test declares catalog, finds the fixture with that name, runs it and injects the result. Every test gets a freshly built catalog — the isolation of setUp, without the class.

And tearDown? Here pytest connects with your generators from 08-03: a fixture with yield does the setup before the yield and the cleanup after it:

@pytest.fixture
def log_capture():
    # --- setup (like setUp) ---
    logger = configure_test_logger()
    yield logger              # ← the test runs here
    # --- cleanup (like tearDown), runs even if the test fails ---
    logger.handlers.clear()

It's literally the generator pattern: the code pauses at the yield, the test consumes the value, and when the test finishes pytest resumes the function to run the cleanup. Setup and teardown in one function, with variables shared naturally. If several suites need the same fixture, it moves to a special file, tests/conftest.py, and pytest makes it visible to every test without importing it.

tmp_path: testing files without touching data/

load_catalog(path: Path) -> dict[str, Book] and close_till(sales_path: Path) -> float work with files (M6). Testing them against the real data/catalog.csv would violate repeatability: the test would fail the day Ana adds a book. pytest gifts you the tmp_path fixture: a Path (the pathlib from M6!) to a temporary directory unique per test, which pytest creates and destroys for you.

from papyrus.warehouse import load_catalog, close_till


def test_load_missing_catalog_returns_empty(tmp_path):
    # A Path to a file that definitely does NOT exist: M7 contract → {} + warning
    result = load_catalog(tmp_path / "does_not_exist.csv")
    assert result == {}


def test_close_till_sums_only_valid_rows(tmp_path):
    # Arrange: a fake sales.csv, with one corrupt row
    path = tmp_path / "sales.csv"
    path.write_text(
        "date,title,amount\n"
        "2026-07-10,Hamlet,20.70\n"
        "2026-07-10,The Odyssey,GARBAGE\n"   # corrupt row: must be skipped
        "2026-07-11,Faust,21.84\n",
        encoding="utf-8",
    )
    # Act + Assert: tolerant of corrupt rows (M7/M8 contract)
    assert close_till(path) == pytest.approx(42.54)

Look at what we just achieved: we tested close_till's corrupt-row-tolerant behavior — impossible to verify against the real sales.csv without dirtying it — in a throwaway directory, with the same write_text/encoding="utf-8" you've mastered since M6. The test is fast, isolated and repeatable on any of the partners' laptops.

Parametrization with @pytest.mark.parametrize

Exercise 2 from 09-02 (the four member prices, with a loop and subTest) has a canonical form in pytest:

@pytest.mark.parametrize(
    "title, base, expected",
    [
        ("The Odyssey", 12.50, 12.35),
        ("Hamlet", 9.95, 9.83),
        ("Don Quixote", 15.90, 15.71),
        ("Faust", 21.00, 20.75),
    ],
)
def test_canonical_member_price(title, base, expected):
    book = Book(title, base, 1)
    assert book.final_price(member=True) == pytest.approx(expected, abs=0.01)

The decorator generates four independent tests (one per tuple): pytest -v lists them as test_canonical_member_price[The Odyssey-12.5-12.35], and so on, and if two fail, you see both. Adding a fifth book to the canonical catalog will mean adding one line to the list. Data and logic, kept apart.

Running: pytest -v, -k and friends

Command What it does
pytest Discovers and runs the whole suite
pytest -v Verbose: one line per test, with parameters
pytest -k "member" Only the tests whose name contains "member"
pytest tests/test_warehouse.py Only one file
pytest -x Stops at the first failure (to debug one at a time)
pytest --lf last failed: reruns only the ones that failed last time

The natural workflow: full pytest after every change; -k or --lf while you chase a specific failure; -v when you want to read the suite as the list of contracts it is.

The next rung of maturity: unittest.mock (just the map)

Let's be honest: there are things this lesson doesn't solve. How do you test a function that calls an API over the network, checks the current time, or depends on something slow or non-deterministic? The professional answer is test doubles (mocks): fake objects that replace the real dependency during the test. Python ships them in unittest.mock (compatible with pytest). It's the next rung in a tester's maturity, and it comes up naturally when Papyrus talks to external services — which starts happening in module 10, with the web. For now, hold onto the name and this rule: if your function is hard to test because it depends on half the world, it's usually the design asking to be decoupled, not the test asking for magic.

Common Mistakes and Tips

  • Installing pytest outside the venv (or with the venv deactivated): then pytest "doesn't exist" or runs against another Python. Check with pip list inside the venv (M1).
  • Forgetting to ask for the fixture as a parameter: if you write def test_x(): and use catalog inside, you get a NameError. Injection happens only if the parameter is named exactly like the fixture.
  • Mutating a fixture believing it's shared (or the other way around): by default each test gets a new fixture (scope="function"). If you switch to scope="module" for speed, the tests are back to sharing mutable state — the ghost of M4. With mutable data, stay on the default scope.
  • pytest.raises(Exception): catching the most generic exception makes the test pass even if a different error than expected fires. Catch the concrete class (InsufficientStockError), as module 7 taught you with except.
  • Building paths by hand instead of using tmp_path: Path("test_tmp.csv") leaves litter in the project and collides between tests. tmp_path is unique per test and cleans up after itself.
  • Tip: run pytest before you start changing code, not just after. Knowing you started from green turns any later red into pure information.

Exercises

Exercise 1

Rewrite in pytest style the three restock tests from exercise 1 of 09-02, using the catalog fixture (you only need Hamlet) and pytest.raises with match= for the missing-title case (the BookNotFoundError message contains the title).

Exercise 2

Write a parametrized test test_non_member_price with the four canonical books and their final prices without the discount (calculate: base × 1.04, rounded to 2 decimals) using pytest.approx.

Exercise 3

Using tmp_path, write test_close_till_empty_file_returns_zero: create a sales.csv containing only the header date,title,amount and check that close_till returns 0.0.

Solutions

Exercise 1

def test_restock_adds_stock_and_returns_none(catalog):
    assert restock(catalog, "Hamlet", 5) is None
    assert catalog["Hamlet"].stock == 11

def test_restock_missing_raises_error(catalog):
    with pytest.raises(BookNotFoundError, match="Moby-Dick"):
        restock(catalog, "Moby-Dick", 5)

def test_restock_zero_units_does_not_mutate(catalog):
    with pytest.raises(ValueError):
        restock(catalog, "Hamlet", 0)
    assert catalog["Hamlet"].stock == 6

Exercise 2 — the expected values: 12.50×1.04=13.00; 9.95×1.04=10.348→10.35; 15.90×1.04=16.536→16.54; 21.00×1.04=21.84.

@pytest.mark.parametrize(
    "title, base, expected",
    [
        ("The Odyssey", 12.50, 13.00),
        ("Hamlet", 9.95, 10.35),
        ("Don Quixote", 15.90, 16.54),
        ("Faust", 21.00, 21.84),
    ],
)
def test_non_member_price(title, base, expected):
    assert Book(title, base, 1).final_price() == pytest.approx(expected, abs=0.01)

Exercise 3

def test_close_till_empty_file_returns_zero(tmp_path):
    path = tmp_path / "sales.csv"
    path.write_text("date,title,amount\n", encoding="utf-8")
    assert close_till(path) == pytest.approx(0.0)

A file with only the header is a classic edge (the "edges" family from 09-01): zero rows to sum must not be an error, but 0.0.

Conclusion

The Papyrus suite has slimmed down and bulked up at the same time: tests that are functions, naked assert with introspection that shows the values on failure, pytest.raises(..., match=) for the M7 exceptions, pytest.approx for the prices, fixtures injecting a fresh catalog (and doing setup+teardown with a yield, like your generators), tmp_path to test load_catalog and close_till without brushing against data/, and parametrize pinning the four canonical member prices in a table. So far, however, we've always followed the same order: first the code, then its tests. In the next lesson we'll reverse the arrow: write the test before the code — red, green, refactor — and discover that tests don't just verify the design: they produce it. We'll do it with a new piece of Papyrus that Ana has been asking for for weeks: discount coupons.

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