Coverage that finds risk
Measure statements and branches with pytest-cov, interpret missing paths, and set useful coverage policy.
Coverage that finds risk
Coverage reports which code paths executed during tests. It does not tell you whether assertions were meaningful, requirements were correct, or production integrations work. Use it to find untested risk—not as the sole definition of quality.
Install pytest-cov
uv add --dev pytest-covRun statement and branch coverage:
uv run pytest \
--cov=your_package \
--cov-branch \
--cov-report=term-missing \
--cov-report=htmlOn PowerShell, place the command on one line or use the backtick as its continuation character.
Open htmlcov/index.html for line-by-line exploration.
Configure once
[tool.pytest.ini_options]
addopts = [
"-ra",
"--strict-markers",
"--cov=your_package",
"--cov-branch",
"--cov-report=term-missing",
]
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["your_package"]
parallel = true
[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under = 85
exclude_also = [
"if TYPE_CHECKING:",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
[tool.coverage.paths]
source = [
"src/your_package",
"*/site-packages/your_package",
]Avoid defining the same option inconsistently in pytest arguments and coverage configuration. Keep the package source explicit so tests themselves do not inflate the percentage.
Why branch coverage matters
Consider:
def shipping_cost(subtotal: int, expedited: bool) -> int:
if subtotal >= 5_000:
return 0
if expedited:
return 1_500
return 500One test may execute every statement yet miss a decision outcome. Branch coverage records whether each conditional took both the true and false path.
import pytest
@pytest.mark.parametrize(
("subtotal", "expedited", "expected"),
[
(5_000, False, 0),
(4_999, True, 1_500),
(4_999, False, 500),
],
)
def test_shipping_cost(
subtotal: int,
expedited: bool,
expected: int,
) -> None:
assert shipping_cost(subtotal, expedited) == expectedBoundary values such as 4_999 and 5_000 are more informative than arbitrary middle values.
Read the report as a question list
For each missing line or branch, ask:
- Is the path reachable?
- Does it encode business behavior or defensive plumbing?
- What failure would occur if it were wrong?
- Which test level can verify it without excessive coupling?
- Should genuinely unreachable code be removed instead of excluded?
High-risk missing paths include authorization failures, transaction rollback, timeout handling, retry limits, parsing errors, and destructive operations.
Sensible thresholds
A repository-wide threshold prevents large regressions:
uv run pytest --cov-fail-under=85But a single percentage can hide an untested new module behind a well-tested old module. Add diff-coverage in CI when possible, or review changed files explicitly.
Raise thresholds gradually. A sudden target of 100% often produces low-value tests that assert implementation details. Critical pure domain logic can reasonably approach 100%; thin adapters may be better covered by a smaller number of integration tests.
Combine parallel runs
If CI splits tests across processes or operating systems, write data files and combine them:
coverage erase
coverage run --parallel-mode -m pytest tests/unit
coverage run --parallel-mode -m pytest tests/integration
coverage combine
coverage report --show-missingWith pytest-xdist, pytest-cov manages subprocess data for normal usage:
uv add --dev pytest-xdist
uv run pytest -n auto --cov=your_package --cov-branchThe [tool.coverage.paths] mapping lets reports combine installed source paths from different CI
workers.
Subprocess and application startup coverage
Code launched as a separate process is not always measured automatically. Prefer testing an
importable main(argv) -> int directly. Reserve subprocess tests for packaging, signal handling, or
true command-line behavior, and configure coverage subprocess support only when needed.
Exclusions require evidence
Good exclusion candidates are structural lines whose runtime behavior is irrelevant, such as
TYPE_CHECKING imports. Avoid excluding:
- exception handlers because they are difficult to trigger;
- platform branches without a platform-specific CI job;
- abstract methods that contain real validation;
- defensive
elseblocks that signal data corruption.
Every exclusion makes code invisible to the metric. Keep the list short and reviewed.
Common pitfalls
- Running
--cov=.: this may measure tests, build scripts, and generated files. - Line coverage only: decisions can remain half-tested.
- Asserting coverage but not behavior: execution without a meaningful assertion is weak evidence.
- Stale data: use
coverage erasewhen manually combining runs. - Ignoring import-time code: coverage may start after modules were imported by another tool.
- Chasing 100% mechanically: test risk, boundaries, and invariants first.
Practice
- Add branch coverage to an existing suite and identify one partial branch.
- Write boundary tests that close the gap without inspecting private implementation.
- Generate terminal, HTML, and XML reports; note which consumer needs each.
- Set a realistic baseline threshold, then intentionally lower coverage to verify CI fails.
- Review all exclusions and remove one that hides testable error handling.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK