Skip to main content

Chapter 52: pytest Deep Dive

James has fifteen test functions spread across his SmartNotes test file. Every single one starts the same way: three lines that create a Note, two lines that build a list of notes, one line that sets up a tag string. He copies and pastes the setup block into test number sixteen and pauses. Six lines of identical setup, duplicated sixteen times. If he changes the Note constructor (say, adding a created_at field in a future chapter), he will need to update sixteen copies of that setup code.

He knows this is wrong. He has spent three chapters learning that duplication is a signal. Duplicated type annotations led to dataclasses. Duplicated branch logic led to helper functions. But duplicated test setup? He does not know the fix.

Emma looks at his screen. "How many times did you paste those same six lines today?" James scrolls. Sixteen times. "What if pytest could run that setup once and hand the result to every test that needs it?" She types two words above one of his setup blocks: @pytest.fixture.

James refactors. One fixture function builds the Note. Another builds the list. His sixteen test functions shrink from eight lines each to three: accept the fixture, act, assert. The duplication vanishes.

Then a different problem surfaces. He has eight tests for categorize_note(), and they all follow the same structure: pass a word count, check the returned category string. The only things that change between them are the input number and the expected output. Eight functions doing the same thing with different data.

Emma again: "What if one function could test all eight?" She shows him @pytest.mark.parametrize. One test function, one decorator, eight input/output pairs in a list. Eight tests, one function.

Later that afternoon, James asks Emma how she knows which parts of her code have tests and which do not. She pauses. "I learned that the hard way. I once wrote over two hundred tests for a project and felt confident everything was covered. Then a user hit a bug in a function I had never tested at all. I had been testing the same code paths repeatedly while entire modules sat untouched." She pulls up a terminal. "Now I always run pytest --cov before I call a test suite complete. Coverage does not tell you your tests are good, but it tells you where you have no tests at all."

This chapter takes the basic pytest skills you built in Chapter 46 and transforms them into a professional testing toolkit. You have been writing tests since your first TDG cycle: a function stub, a test that calls it, then AI-generated implementation. Those tests worked, but they were simple. One test per function, flat structure, no reuse. Real test suites for real codebases need more. They need shared setup that does not repeat. They need one test function that covers dozens of input combinations. They need explicit tests for error cases and boundary conditions. And they need a way to measure what is tested and what is not.

Why Testing Tools Matter for Specification

In Chapter 46, you learned that tests ARE specifications. A test that calls categorize_note(50) and asserts "short" is a specification: it declares what the function must do for that input. But a single test is a single specification point. A function with three branches needs at least three specification points. A function that accepts a range of integers might need boundary tests at 0, 1, the threshold values, and large numbers.

Writing those specification points one at a time, each in its own function with its own setup code, does not scale. Fixtures let you define the test world once and share it. Parametrize lets you define the specification as a table of inputs and outputs. Coverage tells you which parts of your code have no specification at all. Together, these tools turn "I wrote some tests" into "I have a complete, maintainable specification."

What You Will Learn

By the end of this chapter, you will be able to:

  • Structure every test using the Arrange-Act-Assert (AAA) pattern with clear separation between phases
  • Write @pytest.fixture functions and share them across test files using conftest.py
  • Use @pytest.mark.parametrize to test multiple inputs with one function and apply markers to categorize tests
  • Test that functions raise the correct exceptions using pytest.raises and verify edge cases
  • Measure test coverage with pytest --cov and identify untested code paths

Chapter Lessons

LessonTitleWhat You DoDuration
1Test Structure: Arrange, Act, AssertName the three phases, refactor flat tests into AAA form, recognize when phases are missing~20 min
2Fixtures: Reusable Test SetupWrite @pytest.fixture functions, use conftest.py, eliminate duplicated setup~25 min
3Parametrize and MarkersUse @pytest.mark.parametrize for table-driven tests, apply custom markers, run subsets~20 min
4Testing Exceptions and Edge CasesUse pytest.raises for error cases, test boundaries (0, empty, None), negative tests~20 min
5Coverage: Proving What's TestedRun pytest --cov, read coverage reports, find untested branches, close coverage gaps~25 min
6Chapter 52 Quiz50 scenario-based questions covering all pytest concepts~15 min

PRIMM-AI+ in This Chapter

Every lesson includes a PRIMM-AI+ Practice section following the five-stage cycle from Chapter 42. This is Phase 3: you are now WRITING professional test suites, building on the dataclasses (Chapter 51), control flow (Chapter 50), and function signatures (Chapter 49) you already own.

StageWhat You DoWhat It Builds
Predict [AI-FREE]Predict how many tests pass, what a fixture returns, or which parametrize case fails, with a confidence score (1-5)Calibrates your intuition for test behavior
RunExecute pytest, compare actual pass/fail results to your predictionCreates the feedback loop
InvestigateWrite a trace artifact explaining why a test passed or failed, or why coverage missed a branchMakes your testing reasoning visible
ModifyAdd a parametrize case, change a fixture, or introduce an edge case and predict the new resultsTests whether your understanding transfers
Make [Mastery Gate]Write a complete test suite from scratch with fixtures, parametrize, edge cases, and coverageProves you can specify behavior through tests independently

Syntax Card: Chapter 52

Reference this card while working through the lessons. Every construct shown here appears in at least one lesson.

# -- Arrange-Act-Assert (AAA) Pattern ----------------------
def test_categorize_short_note() -> None:
# Arrange
note: Note = Note(title="Quick thought", body="Short text", word_count=2, tags=["idea"])

# Act
result: str = categorize_note(note)

# Assert
assert result == "short"


# -- @pytest.fixture ----------------------------------------
import pytest
from smartnotes.models import Note

@pytest.fixture
def sample_note() -> Note:
return Note(title="Test Note", body="Some body text here", word_count=5, tags=["test"])

def test_note_has_title(sample_note: Note) -> None:
assert sample_note.title == "Test Note"


# -- conftest.py (shared fixtures) -------------------------
# tests/conftest.py
@pytest.fixture
def sample_notes() -> list[Note]:
return [
Note(title="First", body="Body one", word_count=2, tags=["a"]),
Note(title="Second", body="Body two", word_count=2, tags=["b"]),
]


# -- @pytest.mark.parametrize ------------------------------
@pytest.mark.parametrize(
"word_count, expected",
[
(5, "short"),
(50, "short"),
(100, "medium"),
(200, "medium"),
(500, "long"),
],
)
def test_categorize_by_word_count(word_count: int, expected: str) -> None:
assert categorize_by_count(word_count) == expected


# -- pytest.raises (exception testing) ---------------------
def test_empty_title_raises() -> None:
with pytest.raises(ValueError, match="title cannot be empty"):
Note(title="", body="text", word_count=1, tags=[])


# -- Coverage -----------------------------------------------
# Terminal command:
# pytest --cov=smartnotes --cov-report=term-missing
#
# Output shows:
# Name Stmts Miss Cover Missing
# smartnotes/models.py 12 0 100%
# smartnotes/logic.py 28 4 86% 31-34

Prerequisites

Before starting this chapter, you should be able to:

  • Define @dataclass types with typed fields and defaults (Chapter 51)
  • Write if/elif/else branches and for loops (Chapter 50)
  • Write function signatures with type annotations and docstrings (Chapter 49)
  • Run basic pytest test functions and complete TDG cycles (Chapter 46)

The SmartNotes Connection

At the end of this chapter, your SmartNotes test suite will be a comprehensive specification. You will build a test suite of 15 to 20 tests using every tool from this chapter:

  • Fixtures that construct Note and Notebook instances so no test repeats setup code
  • Parametrize tables that specify categorize_note() behavior across every boundary value
  • Exception tests that verify Note construction rejects invalid inputs (empty titles, wrong types)
  • Edge case tests for empty lists, single-element lists, and notes with no tags
  • Coverage measurement that proves every branch in your SmartNotes functions has at least one test

The test file becomes the specification. Anyone who reads your tests knows exactly what your SmartNotes code promises to do, what inputs it accepts, what it rejects, and where the boundaries fall. Tests are not just verification; they are documentation that the computer can execute.