Skip to content

Measuring Code Coverage with coverage.py

Coverage measures which lines/branches ran during tests.

Coverage helps you find:

  • untested code paths
  • dead code

Coverage does not guarantee correctness.

Common setup:

  • pytest-cov uses coverage.py

Run:

bash
pytest --cov=src --cov-report=term-missing

HTML report:

bash
pytest --cov=src --cov-report=html
  • aim for meaningful coverage
  • focus on risk areas
  • don’t chase 100% blindly
commands
pytest --cov=shop --cov-report=term-missing
pytest --cov=shop --cov-report=html         # browsable, htmlcov/index.html
pytest --cov=shop --cov-report=xml          # for CI tools
pytest --cov=shop --cov-fail-under=80       # exit 1 below the threshold

Measured on a small module:

term-missing
Name      Stmts   Miss  Cover   Missing
---------------------------------------
shop.py      12      2    83%   11, 14

Missing is the column that matters. 83% tells you how much; lines 11 and 14 tell you what to do next.

The measurement that should change how you read a coverage number

Section titled “The measurement that should change how you read a coverage number”

A module with one if, and a single test that only exercises the true branch:

branchy.py
def classify(n):
    label = "small"
    if n > 10:
        label = "big"
    return label
test_branchy.py
def test_big():
    assert classify(50) == "big"
statement coverage
branchy.py   5   0   100%
with --cov-branch
branchy.py   5   0   2   1   86%   3->5

100% against 86%. Nothing ever passed a small number, so the if was never false, and the 3->5 branch — falling straight from the condition to the return — was never taken. Statement coverage cannot see it, because every line did execute.

diagram Diagram mermaid

Turn it on. It is one flag:

pyproject.toml
[tool.coverage.run]
branch = true
source = ["myapp"]
omit = ["*/migrations/*", "*/tests/*"]
 
[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = [
  "pragma: no cover",
  "if TYPE_CHECKING:",
  "raise NotImplementedError",
]
measured exit codes
pytest --cov=shop --cov-fail-under=90     # actual 83%  ->  exit 1
pytest --cov=shop                          #             ->  exit 0

A threshold is a ratchet, not a target. Setting it slightly below the current number stops coverage falling; setting it at 100 encourages tests written to touch lines rather than to check behaviour.

The useful reading is not the percentage but the Missing column and the HTML report’s red lines. Uncovered error handling and uncovered branches are where bugs live, because they are exactly the paths nobody exercised by hand either.

sketch 100% statement coverage, 86% branch coverage p5.js
One test through the true side of a condition executes every line while never taking the false branch.
pch.quizTag pch.quizDefaultTitle
  1. A module scored 100% statement coverage and 86% branch coverage from the same single test. How?

    pch.quizShowAnswer

    B — every line executed, but one outcome of an if never happened — the false branch was never taken — Measured the missing branch reported as 3->5. An if with no else can reach 100% statement coverage from a single test that only takes the true side.

  2. What does this test contribute to coverage: def test_it_runs(): price_with_tax(100)?

    pch.quizShowAnswer

    B — full coverage of every line it executes, while verifying nothing at all — Coverage measures execution, not verification. It tells you what your tests touched, never what they checked.

  3. pytest --cov=shop --cov-fail-under=90 on a module at 83% exits with what code?

    pch.quizShowAnswer

    B — 1 — Measured exit 1. A threshold works best as a ratchet just below the current number, to stop coverage falling rather than to chase a target.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading