Coverage tools tell you which lines and branches of your code your tests actually ran. They don't tell you whether your tests are good, but they're great at finding the code paths nobody is exercising. This chapter covers coverage.py, the pytest-cov plugin, the difference between line and branch coverage, how to read the reports, how to configure exclusions, and where coverage stops being useful. Coverage is the layer that sits on top of pytest basics, fixtures, parametrize, and mocking.
When you run your tests under a coverage tool, it instruments your code so that every time a line executes, it gets recorded. After the test run, you get a report saying "out of 200 lines in this module, 174 ran and 26 didn't". The lines that didn't run are the ones no test reached.
That's the whole mechanism. There's no magic. The tool watches your code execute and writes down what it sees. The interesting question isn't "how does it work" but "what does the answer mean".
A high coverage number means your tests touched a lot of code. It does not mean your tests verified that code behaves correctly. A test that calls calculate_discount(100, "gold") and asserts nothing still counts every line of calculate_discount as covered. The line ran. The tool can't tell whether anyone checked the result.
So coverage is a "tests didn't reach here" detector. Use it to find blind spots, not to prove quality.
The output of the loop is a list of line numbers your tests never touched. You read that list, look at the code, and decide whether each missed line is a real gap (write a test) or genuinely unreachable (mark it as excluded).
coverage.py is the underlying tool. pytest-cov is a thin pytest plugin that calls into it. Most projects install both via the plugin:
That pulls coverage in as a dependency. You don't have to install it separately.
The simplest run looks like this. Suppose your package is named shop and lives at shop/:
That tells pytest to run the test suite and measure coverage for the shop package. The output adds a small table at the end:
Three columns matter:
The total at the bottom is the weighted average across all files. A 0/0 file (empty __init__.py) reports 100% by convention.
If you want to see which lines specifically weren't covered, ask for the missing report:
That changes the table to include a Missing column:
Now you can open shop/discount.py, jump to lines 17-19, and decide whether those lines need a test.
Cost: Coverage instrumentation typically slows the test suite by 5 to 20 percent because Python's trace hook fires on every line. For small suites it's invisible. For a large suite that already takes minutes, consider running coverage only in CI or behind a flag.
By default, coverage.py measures line coverage: did this line of code execute, yes or no. That's enough for straight-line code, but it misses something important about conditionals.
Look at this DiscountCalculator:
Now suppose you only write one test:
Run line coverage and you'll see line 2 (the if) ran, line 3 ran, and line 8 (the return) ran. Lines 4, 5, 6, and 7 didn't. So you'll get a "missing 4-7" report, which correctly tells you the silver and default paths aren't tested. Fine.
But consider a smaller example where the else is missing entirely:
If your only test is round_price(19.999), line coverage says everything ran except line 3. Easy to spot.
Now what if your only test is round_price(-5)? Line 2 ran (the if), line 3 ran (the assignment), line 4 ran. Line coverage reports 100% for this function. But you've never tested what happens when amount >= 0 directly skips the if body. The decision point was only exercised in one direction.
Branch coverage fixes this. It records each conditional outcome separately: did the if go true at least once, and did it go false at least once. To turn it on, add --cov-branch:
With branch coverage on, the second example above reports:
The new columns:
from->to form. 2->4 means "the if on line 2 going directly to line 4 was never taken" (the False direction in this case).Here's the same comparison as a diagram. Line coverage cares only about which boxes were entered. Branch coverage cares which arrows were taken.
The solid arrow is the path your single test took. The dashed arrow is the path branch coverage flags as missing, even though every line on both paths technically ran. Line coverage would have reported 100% and you'd never have known.
The fix is the test you didn't write yet:
Now both branches are taken and BrPart drops to zero.
Cost: Branch coverage is more expensive to compute than line coverage, usually 10 to 30 percent slower than already-instrumented line coverage. For most projects the extra signal is worth it. Turn it on in CI even if you skip it locally.
The terminal report is fine for "did anything regress". For actually fixing gaps, the HTML report is much better. Generate it with:
That writes a directory called htmlcov/ to the current working directory. Open htmlcov/index.html in a browser. The index page lists every covered file with the same columns as the terminal output, but clickable.
Click a file and you get the full source listing, color-coded. Each line gets a small marker on the left:
Hover over a yellow line and the report tells you which target line numbers were missed. So if you see line 12 highlighted yellow with partial indicating 12 -> 18, that means the if on line 12 was never taken in the direction that jumps to line 18.
If you need a machine-readable report for CI tooling (Codecov, Coveralls, SonarQube), produce XML:
That writes coverage.xml in Cobertura format, which every coverage dashboard understands. You can produce multiple reports in one run:
The terminal report tells you the headline number, the HTML report lets you investigate, and the XML report uploads to wherever your team aggregates results.
The order of report flags doesn't matter. None of these regenerate coverage data; they're all different views of the same .coverage file written during the test run.
DiscountCalculatorLet's walk through what coverage looks like on a small but realistic module. Imagine an e-commerce store with membership tiers. Each tier gets a different discount, and there's a minimum order to qualify for the gold tier discount.
And the first test file:
Run coverage with branches and missing lines:
The output:
Two missed lines and two missed branches. Reading the missing column:
raise ValueError("price cannot be negative"). No test passes a negative price.discount = 0.05. The bronze tier is never exercised.if items_in_cart >= 3 on line 5 going to line 8 (the else branch) was never taken. We tested gold with 3 items but never gold with fewer than 3.elif tier == "bronze" on line 12 reaching line 14 was never taken, which matches the line-14 miss.The gaps map directly to test cases we should add:
Re-run coverage and the report shows 100% line and branch. That's the loop in practice: run, read the misses, write a focused test for each, run again.
pyproject.tomlOut of the box, coverage tracks everything in the package you point it at. That's usually too much: you don't want to count auto-generated migration files, scripts, or the __main__ block of a module against your coverage number. You also don't want to repeat the same flags every time you run pytest.
The cleanest place to configure all this is pyproject.toml. coverage.py reads two sections, [tool.coverage.run] and [tool.coverage.report]:
What each piece does:
branch = true turns on branch coverage by default, so you can drop --cov-branch from the command line.source is the package to measure. Equivalent to passing --cov=shop.omit is a list of glob patterns. Files matching any pattern are excluded entirely from measurement.show_missing mirrors --cov-report=term-missing.fail_under makes the report fail if total coverage drops below the threshold (more on this in the CI section).exclude_lines is a list of regex patterns. Any line in your source that matches one of these patterns is treated as if it weren't there. The line still runs at runtime, but coverage ignores it.The exclude_lines defaults are worth a closer look. Most projects exclude these four categories:
pragma: no cover is the manual escape hatch. You put # pragma: no cover at the end of a line (or above a block) you want coverage to ignore.raise NotImplementedError is for abstract methods that aren't supposed to run.if __name__ == "__main__": is the script-entrypoint block that tests never reach.if TYPE_CHECKING: is the import block for static type checkers, which is False at runtime.The regex flavor is Python's re module. The escaped dots in if __name__ == .__main__.: match the literal quote characters around __main__.
Once this is in pyproject.toml, your command line shrinks back to:
Everything else is config.
Cost: A wide omit pattern can hide real coverage gaps. Be specific. Don't omit = ["*/utils/*"] just because the utils module is hard to test; tighten the pattern to the specific files (*/utils/legacy_helpers.py) so you can still see the rest.
# pragma: no coverSometimes a single line is genuinely unreachable, or reachable only in a way that doesn't make sense to test. Defensive error handling for "this shouldn't happen" cases is the classic example. You don't want those lines to drag your number down, but you also don't want to omit the whole file.
Use # pragma: no cover on the specific line or block:
Now coverage skips that one line. The rest of the function still counts.
You can also apply the pragma to a block by putting it on the opening line of the block:
The pragma applies to the entire indented block under the line it's on. You only need one pragma comment for the whole if body.
Two rules of thumb when reaching for # pragma: no cover:
A common abuse is to slap pragmas on lines that are hard to test rather than lines that genuinely shouldn't be tested. Resist that. If a line is hard to test, the more honest move is to refactor the code so it's testable, not to hide it from the report.
Some test suites can't run in a single process. You might split tests across multiple machines in CI, or run unit tests and integration tests separately, or run the same suite under multiple Python versions. Each run produces its own .coverage file, and you want the final report to reflect the union of everything that ran.
coverage combine merges them. The flow is:
parallel = true in [tool.coverage.run] (or pass COVERAGE_FILE with different values), and each process writes to .coverage.HOSTNAME.PID.RANDOM instead of overwriting .coverage.coverage combine. It reads every .coverage.* file and writes a single merged .coverage.coverage report or coverage html to view the merged result.A minimal pyproject.toml for this:
A typical CI shell script:
Without parallel = true, each run overwrites the previous .coverage file and you lose data. With it on, both jobs write to distinct files, and combine produces the union.
The same flow works for pytest-xdist, which runs tests in parallel processes on one machine. Each worker writes its own data file; combine merges them before the final report.
The biggest mistake teams make with coverage is treating the percentage as a quality score. It isn't. Coverage tells you exactly one thing: which lines your tests reached. It is silent on every other question that matters.
Consider this function and test pair:
The test calls the function. Every line of calculate_order_total runs. Coverage reports 100%. But the test asserts nothing. If you swap subtotal + tax for subtotal - tax, the test still passes. If you remove the round, the test still passes. The function could return None (it doesn't, but it could) and the test would still pass.
This is the "100% coverage, zero asserts" anti-pattern, and it shows up in real codebases more often than people admit. A team gets pressured to hit a coverage target, writes tests that call code without checking results, and ships a green dashboard that proves nothing.
The fix is review, not a different tool. Coverage doesn't know what your code is supposed to do, so it can't catch missing assertions. Reading the tests is the only way to catch this.
100% line and branch coverage. Both tests pass. The function works for happy-path strings.
What about "19,99" (European decimal)? "" (empty)? "$" (just the symbol)? "-5" (negative)? "19.99.50" (malformed)? None (not a string at all)?
Every one of those is a bug the tests don't catch. Coverage shows green. The number lies about completeness.
The takeaway: coverage tells you about code you wrote and didn't test. It says nothing about inputs you didn't think of. Edge-case design happens in the test, not in the coverage report.
A weak assertion still counts. assert result is not None covers the same lines as assert result == 80.0. The first proves nothing about the math; the second proves the function returned the right number. Coverage tools cannot tell them apart.
Mutation testing is the tool that actually measures assertion strength: it modifies your code in small ways (+ to -, < to <=, True to False) and checks whether tests fail. If they don't, the assertion was too weak. That's a separate, much harder topic. Coverage is the cheap first pass.
A few rules of thumb that work in practice:
Don't chase 100% as a vanity number. The last 10% is usually error-handling code that's genuinely hard to trigger, and the easiest way to "cover" it is to add # pragma: no cover everywhere, which is exactly the wrong incentive. Aim for "high enough that gaps are deliberate", not "100 percent at any cost".
| Target | When it makes sense |
|---|---|
| 60% | Glue code, prototypes, scripts where the failure mode is "I'll notice" |
| 80% | Most application code; the standard "we take testing seriously" baseline |
| 90% | Library code consumed by other teams or projects |
| 100% | Money-handling, security, regulatory code; small libraries where it's achievable |
| Above 100% | Doesn't exist; chasing it is a smell |
Coverage data is most useful when it gates merges. If a pull request drops the coverage number, you want to find out at PR review time, not three months later when the trend report shows a slow decline.
The simplest gate is --cov-fail-under. Pass a threshold; if total coverage falls below it, pytest exits non-zero:
A run that comes in at 79% prints something like:
and exits with code 2. Any CI system reading the exit code will mark the job as failed.
You can also set the threshold in pyproject.toml so it lives next to the rest of the config:
A few things to think about when picking a threshold:
For deeper analysis, most teams pipe the XML report to a service like Codecov or Coveralls:
Those tools track coverage per file, per pull request, and per commit, and they comment on the PR with a diff showing which lines the PR added that aren't covered. The XML is the same Cobertura format every coverage service understands; you write it once and consume it from whichever dashboard you prefer.
The shape is always the same: tests run, coverage is measured, a threshold check decides whether the build is allowed to pass. Everything else is which threshold, which reports, and which dashboard.
Order State MachineTo pull the chapter together, let's run coverage end to end on a slightly larger example: an Order state machine with explicit transitions. This is the kind of code where branch coverage really earns its keep.
A first round of tests:
Run coverage:
The output (illustrative):
The misses tell a clear story:
raise InvalidTransition(f"unknown current status: {self.status}") branch is never hit. No test puts the order in an unknown status.is_terminal method is never called.if allowed is None going to the raise was never taken.Two of these are real gaps. The unknown-status branch is defensive: it can only fire if someone bypasses transition_to and writes garbage directly to self.status. That's a reasonable candidate for # pragma: no cover, since you can't trigger it through the public API without explicit mucking.
The is_terminal gap is a clean gap. Add a test:
The defensive branch gets a pragma:
Re-run and the report comes back at 100% line and branch, with the pragma accounting for the only real exclusion. The number now reflects what's deliberately tested, not a mix of "actually tested" and "haven't gotten around to it".
That's the loop one more time: run, read the misses, write tests for the real gaps, mark genuine exclusions with pragmas, run again. The point of the tool is to make those gaps visible, not to chase a number.
11 quizzes