Python Atlas
Tooling, web & data

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-cov

Run statement and branch coverage:

uv run pytest \
  --cov=your_package \
  --cov-branch \
  --cov-report=term-missing \
  --cov-report=html

On 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 500

One 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) == expected

Boundary 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:

  1. Is the path reachable?
  2. Does it encode business behavior or defensive plumbing?
  3. What failure would occur if it were wrong?
  4. Which test level can verify it without excessive coupling?
  5. 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=85

But 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-missing

With pytest-xdist, pytest-cov manages subprocess data for normal usage:

uv add --dev pytest-xdist
uv run pytest -n auto --cov=your_package --cov-branch

The [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 else blocks 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 erase when 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

  1. Add branch coverage to an existing suite and identify one partial branch.
  2. Write boundary tests that close the gap without inspecting private implementation.
  3. Generate terminal, HTML, and XML reports; note which consumer needs each.
  4. Set a realistic baseline threshold, then intentionally lower coverage to verify CI fails.
  5. 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

Coverage that finds risk

1A shipping function has two conditionals and all return statements execute across the suite, but the expedited condition is never false below the free-shipping threshold. What does this demonstrate?
2A mature module keeps repository coverage above 85%, but a new authorization module has untested denial branches. What is the best response?