Skip to main content
Pytest intermediate Lesson 8 of 10

Measuring Coverage with pytest-cov

Run coverage from pytest, read the missing-lines report, turn on branch coverage, and see why a suite at 100% can still miss the bug.

Coverage answers one narrow question: which lines did the test suite execute? That is less than it sounds, and still worth knowing — the uncovered lines are the ones no test has ever run.

Setting it up

pip install pytest-cov
Successfully installed coverage-7.6.10 pytest-cov-6.0.0
# shop/orders.py
def total(lines):
    if not lines:
        raise ValueError("an order needs at least one line")

    amount = 0.0
    for sku, qty, price in lines:
        if qty < 0:
            raise ValueError(f"negative quantity for {sku}")
        amount += qty * price

    if amount > 100:
        amount *= 0.95          # bulk discount
    return round(amount, 2)


def describe(order_id, lines):
    if not lines:
        return f"order {order_id}: empty"
    return f"order {order_id}: {len(lines)} line(s), {total(lines):.2f}"
# tests/test_orders.py
from shop.orders import total


def test_single_line():
    assert total([("A1", 2, 4.50)]) == 9.00


def test_multiple_lines():
    assert total([("A1", 2, 4.50), ("B7", 1, 12.00)]) == 21.00
$ pytest --cov=shop -q
..                                                                [100%]

---------- coverage: platform linux, python 3.11.9-final-0 -----------
Name                   Stmts   Miss  Cover
------------------------------------------
shop/__init__.py           0      0   100%
shop/orders.py            15      6    60%
------------------------------------------
TOTAL                     15      6    60%

2 passed in 0.14s

Six statements never ran. The report says how many, not which — for that, ask.

Which lines are missing

$ pytest --cov=shop --cov-report=term-missing -q
..                                                                [100%]

---------- coverage: platform linux, python 3.11.9-final-0 -----------
Name                   Stmts   Miss  Cover   Missing
-------------------------------------------------------
shop/__init__.py           0      0   100%
shop/orders.py            15      6    60%   3, 9, 12, 16-18
-------------------------------------------------------
TOTAL                     15      6    60%

2 passed in 0.15s

Now it is actionable. Line 3 is the empty-order raise, line 9 the negative-quantity raise, line 12 the bulk discount, and 16-18 the whole of describe. Every one of those is a rule the product has and the suite does not check.

Write the missing tests:

import pytest
from shop.orders import total, describe


def test_empty_order_rejected():
    with pytest.raises(ValueError, match="at least one line"):
        total([])


def test_negative_quantity_rejected():
    with pytest.raises(ValueError, match="negative quantity for A1"):
        total([("A1", -1, 4.50)])


def test_bulk_discount_applies_over_100():
    assert total([("A1", 30, 4.50)]) == 128.25


def test_describe_counts_lines():
    assert describe(7, [("A1", 2, 4.50)]) == "order 7: 1 line(s), 9.00"


def test_describe_empty_order():
    assert describe(7, []) == "order 7: empty"
$ pytest --cov=shop --cov-report=term-missing -q
.......                                                           [100%]

---------- coverage: platform linux, python 3.11.9-final-0 -----------
Name                   Stmts   Miss  Cover   Missing
-------------------------------------------------------
shop/__init__.py           0      0   100%
shop/orders.py            15      0   100%
-------------------------------------------------------
TOTAL                     15      0   100%

7 passed in 0.18s

100%. That number is about to be less impressive than it looks.

Branch coverage

$ pytest --cov=shop --cov-branch --cov-report=term-missing -q
.......                                                           [100%]

---------- coverage: platform linux, python 3.11.9-final-0 -----------
Name                   Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------
shop/__init__.py           0      0      0      0   100%
shop/orders.py            15      0      8      1    96%   11->13
---------------------------------------------------------------------
TOTAL                     15      0      8      1    96%

7 passed in 0.19s

11->13 is a partial branch: line 11 is if amount > 100, and no test has taken the path where it is false and then reaches line 13. Every statement ran; one path did not. Branch coverage is where uncovered else-less conditionals show up, and it should be on by default:

# pyproject.toml
[tool.coverage.run]
branch = true
source = ["shop"]
omit = ["*/migrations/*", "*/__main__.py"]

[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]

[tool.pytest.ini_options]
addopts = "--cov=shop --cov-report=term-missing"
$ pytest -q
.......                                                           [100%]

---------- coverage: platform linux, python 3.11.9-final-0 -----------
Name                   Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------
shop/orders.py            15      0      8      1    96%   11->13
---------------------------------------------------------------------
TOTAL                     15      0      8      1    96%

skip_covered hides the files at 100% so the report only shows work to do. exclude_lines drops lines that cannot be meaningfully tested — a TYPE_CHECKING import block is never executed at runtime, and counting it as a miss just trains people to ignore the number.

The HTML report

pytest --cov=shop --cov-report=html
---------- coverage: platform linux, python 3.11.9-final-0 -----------
Coverage HTML written to dir htmlcov

7 passed in 0.28s

Open htmlcov/index.html and every source line is colour-coded, with partial branches marked in the margin. On a file with thirty missing lines this is far faster to read than the terminal table. Add htmlcov/ to .gitignore.

Failing the build

[tool.coverage.report]
fail_under = 90
$ pytest -q
....                                                              [100%]

---------- coverage: platform linux, python 3.11.9-final-0 -----------
TOTAL                     15      4    73%

FAIL Required test coverage of 90% not reached. Total coverage: 73.33%
4 passed in 0.16s
$ echo $?
1

Tests passed; the run failed. The exit code is what CI reads, so this is a real gate.

Set the threshold at or just below where you are now, not at an aspirational number. A gate you cannot meet gets bypassed within a week; a ratchet you raise a point at a time survives. For an existing codebase, tools like diff-cover apply the rule only to lines the pull request changed, which is the version people actually accept.

What coverage does not tell you

def test_total_works():
    total([("A1", 30, 4.50)])
$ pytest --cov=shop --cov-report=term-missing -q
.                                                                 [100%]

Name                   Stmts   Miss  Cover   Missing
-------------------------------------------------------
shop/orders.py            15      6    60%   16-18, 3, 9

That test asserts nothing. It executed the discount branch and the coverage tool counted it, because coverage records execution, not verification. Change the discount from 5% to 50% and this test still passes.

So the number is a floor, not a grade:

  • Uncovered lines are a real finding. Nothing has ever run them.
  • Covered lines are not a guarantee. They ran; something may or may not have checked the result.
  • Do not chase the last few percent. Excluding a __main__ block is honest; writing a test that calls a function to bump the figure is not.

If you want the stronger signal, mutation testing (mutmut, cosmic-ray) changes your code and reports which mutations the suite failed to catch. It is much slower, and much harder to fool.

Coverage with parallel runs

pytest -n 4 --cov=shop

pytest-cov handles the combining for xdist workers itself. If you shard across several CI jobs instead, each writes its own data file and you merge them at the end:

coverage combine
coverage report
Combined data file .coverage.runner-1.1234
Combined data file .coverage.runner-2.5678
Name                   Stmts   Miss  Cover
------------------------------------------
shop/orders.py            15      0   100%

Set relative_files = true under [tool.coverage.run] when the shards run in different directories, or the paths will not line up and nothing will merge.

Practice

1. Run coverage on a module with an untested error path.
Name                   Stmts   Miss  Cover   Missing
-------------------------------------------------------
shop/orders.py            15      1    93%   9

One line, one missing rule. Error paths are the most common gap because they need pytest.raises rather than a plain call, and they are exactly the paths that fail in production.

2. Turn on --cov-branch and find a partial branch.
Name                   Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------
shop/orders.py            15      0      8      1    96%   11->13

The arrow notation is source line to destination line. 11->13 means the false path of the if on line 11 was never taken.

3. Set fail_under above your current coverage and check the exit code.
FAIL Required test coverage of 95% not reached. Total coverage: 92.31%
$ echo $?
1

Non-zero, so CI fails even though every test passed. That is the point of the gate — and the reason to set it at a level the team can actually hold.

4. Write a test that calls a function without asserting, then read the report.
shop/orders.py            15      0   100%

Full coverage, zero verification. Coverage cannot distinguish this test from a good one, which is the single most important thing to know about the metric.

Next: pytest’s plugin system — adding your own command-line flags and hooking into the run.

Frequently Asked Questions

What is the difference between statement and branch coverage?
Statement coverage asks whether each line ran. Branch coverage asks whether each if took both its true and false paths. An if with no else can reach 100% statement coverage while the skip path was never exercised, which is why --cov-branch finds more.
What coverage percentage should I aim for?
There is no universal number. Useful practice is to keep the figure from falling — set fail_under just below current coverage — and to require new code to be covered, rather than chasing 100% across a legacy codebase.
Why does coverage report 0% for my package?
Almost always the --cov target. Passing --cov=src measures the directory, but if the tests import the installed copy of the package, the measured files are never executed. Point --cov at the importable package name, and prefer an editable install.
Can a fully covered function still be wrong?
Yes. Coverage records which lines executed, not whether anything was asserted about them. A test that calls a function and asserts nothing reports full coverage of it, which is why coverage is a floor rather than a measure of test quality.