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.