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
- Installing in the venv and the first test
- Naked
assertwith superpowers: introspection - The same suite, rewritten: unittest vs pytest
- Exceptions with
pytest.raisesand floats withpytest.approx - Fixtures:
setUpturned into a function (and withyield) tmp_path: testing files without touchingdata/- Parametrization with
@pytest.mark.parametrize - Running:
pytest -v,-kand friends - 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):
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 # AssertRun 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_priceYou 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 NoneThe 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 == 4match="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 centFor 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 withpip listinside the venv (M1). - Forgetting to ask for the fixture as a parameter: if you write
def test_x():and usecataloginside, you get aNameError. 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 toscope="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 withexcept.- Building paths by hand instead of using
tmp_path:Path("test_tmp.csv")leaves litter in the project and collides between tests.tmp_pathis unique per test and cleans up after itself. - Tip: run
pytestbefore 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 == 6Exercise 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
- 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
