Intermediate
You write a test suite, run it, and all tests pass. Green checkmarks everywhere. But how do you know those tests actually exercise the code paths that matter? You might have 50 tests that all hit the happy path while every error handler, edge case, and fallback branch sits completely untested — waiting to blow up in production. Coverage.py answers the one question pytest alone cannot: which lines of your code were never executed during testing?
Coverage.py is a Python standard-bearer for test coverage measurement. It instruments your code at the bytecode level, tracking exactly which lines, branches, and conditions were executed while your tests ran. The results can be printed to the terminal, saved as an XML report for CI pipelines, or rendered as a browsable HTML report that color-codes every file green (covered) or red (missed). It integrates directly with pytest through the pytest-cov plugin, so there is nothing complex to wire up — just install and run.
In this article you will learn how to install coverage.py and pytest-cov, run coverage measurement alongside your existing test suite, interpret the terminal and HTML reports, configure a .coveragerc file to exclude irrelevant paths, enforce a minimum coverage threshold in CI, and apply branch coverage to catch untested conditional logic. By the end you will be able to add meaningful coverage measurement to any Python project in under five minutes.
Measuring Test Coverage: Quick Example
Before diving into configuration and advanced features, here is the simplest possible coverage run. Given a tiny module and its tests, this shows the full workflow from install to report in a handful of commands.
# mymath.py
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
# test_mymath.py
from mymath import add, subtract
def test_add():
assert add(2, 3) == 5
def test_subtract():
assert subtract(10, 4) == 6
# Run tests with coverage
$ pip install pytest pytest-cov
$ pytest --cov=mymath test_mymath.py
---------- coverage: platform linux, python 3.12 ----------
Name Stmts Miss Cover
-------------------------------
mymath.py 6 2 67%
-------------------------------
TOTAL 6 2 67%
2 passed in 0.12s
The report immediately reveals that mymath.py has 67% coverage — 2 statements were never executed. Those two missed statements are the lines inside multiply(), which has no test. Coverage.py counted 6 executable statements, 2 were missed, and the math gives 67%. The sections below show how to find exactly which lines were missed, configure what gets measured, and enforce coverage thresholds.
What is coverage.py and Why Does It Matter?
Coverage.py is a tool that monitors your Python program while it runs and notes which parts of the code have been executed. It was created by Ned Batchelder and is the de facto standard for Python test coverage. The term “coverage” here means statement coverage: what percentage of your source lines were touched by at least one test.
There are two measurement modes:
| Mode | What It Measures | Best For |
|---|---|---|
| Statement coverage | Which lines ran | Finding untested functions/modules |
| Branch coverage | Which if/else paths ran | Finding uncovered logic branches |
Branch coverage is stricter. A line like return x if x > 0 else 0 counts as covered under statement coverage if the line ran at all — even if you never tested the negative case. With branch coverage enabled, coverage.py tracks both the true and false branches separately. For most projects, starting with statement coverage and adding branch coverage once your baseline is solid is the practical approach.
Installing coverage.py and pytest-cov
You have two install options depending on how you run your tests. If you use pytest (which most Python projects do), install pytest-cov. This is a pytest plugin that wraps coverage.py and integrates it directly into the pytest runner — no separate step needed.
# install_coverage.sh
pip install pytest-cov
Successfully installed coverage-7.5.0 pytest-cov-5.0.0
The pytest-cov package automatically installs coverage as a dependency. If you use a different test runner (like unittest or nose), install coverage directly and use the coverage run command instead. For most projects using pytest, pytest-cov is all you need.
Running Coverage with pytest
The --cov flag tells pytest-cov which source directory or module to measure. Pass the package or module name, not the test file — you want to measure your application code, not your tests measuring themselves.
# run_coverage.sh
# Measure coverage for a single module
pytest --cov=mymath tests/
# Measure coverage for an entire package
pytest --cov=myapp tests/
# Show which lines were missed in the terminal
pytest --cov=mymath --cov-report=term-missing tests/
---------- coverage: platform linux, python 3.12 ----------
Name Stmts Miss Cover Missing
-----------------------------------------
mymath.py 6 2 67% 12-13
-----------------------------------------
TOTAL 6 2 67%
2 passed in 0.14s
The --cov-report=term-missing flag adds the “Missing” column showing exactly which line numbers were not executed. In this example, lines 12-13 (the body of multiply()) were never run. Now you know exactly what to test next. The --cov-report flag accepts multiple values and can be specified more than once in the same command to generate several report formats simultaneously.
Generating an HTML Coverage Report
The terminal report is useful for a quick check, but the HTML report is far more powerful for investigating what was missed. It produces a browsable set of HTML pages where each file is color-coded: green for covered lines, red for missed lines, and yellow for partially-covered branches.
# generate_html_report.sh
pytest --cov=mymath --cov-report=html tests/
---------- coverage: platform linux, python 3.12 ----------
Coverage HTML written to dir htmlcov
2 passed in 0.15s
Open htmlcov/index.html in a browser. You will see a table listing every measured file with its coverage percentage. Click any file name to see the annotated source code. Covered lines have a green background, missed lines have a red background. This makes it easy to spot entire functions that were never called or error handlers that were skipped.
Configuring .coveragerc
As your project grows, you will want to exclude certain files from coverage measurement: test files themselves, migration scripts, generated code, or __main__ blocks that only run interactively. The .coveragerc file (placed in the project root) centralizes all coverage configuration so you do not need to repeat flags on every pytest invocation.
# .coveragerc
[run]
source = myapp
branch = True
omit =
myapp/migrations/*
myapp/settings.py
*/tests/*
*/__main__.py
[report]
fail_under = 80
show_missing = True
skip_covered = False
[html]
directory = htmlcov
The key settings: source sets the default package to measure (replaces the --cov flag), branch = True enables branch coverage, omit is a glob list of paths to exclude, and fail_under = 80 makes coverage.py exit with a non-zero status code if coverage drops below 80%. That last setting is what makes coverage gates work in CI — your CI pipeline checks the exit code and fails the build if coverage regresses.
Enabling Branch Coverage
Statement coverage can give you a false sense of security on conditional code. Consider a function that takes different paths depending on input — statement coverage says “that line ran” but does not check whether all branches were exercised. Branch coverage fixes this.
# discount.py
def calculate_discount(price, is_member):
if price > 100:
if is_member:
return price * 0.80 # 20% off for members on big orders
else:
return price * 0.90 # 10% off for everyone on big orders
return price # no discount for small orders
# test_discount.py
from discount import calculate_discount
def test_member_discount():
assert calculate_discount(150, True) == 120.0
def test_large_order_nonmember():
assert calculate_discount(150, False) == 135.0
# Run with branch coverage
pytest --cov=discount --cov-branch --cov-report=term-missing tests/
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------
discount.py 6 1 4 1 80% 8->exit, 9
The output now shows Branch (4 branches), BrPart (1 partial branch), and flags line 9 and the 8->exit branch — the case where price <= 100 and the function returns early. No test calls calculate_discount with a price below 100. Without branch coverage, you would never see this gap because all lines in the file were executed by at least one test.
Using Coverage in CI Pipelines
Coverage checks are most valuable when they run automatically on every pull request. The fail_under setting in .coveragerc makes the process straightforward — no additional scripting needed. The pytest command exits with status code 2 when coverage falls below the threshold, which CI systems interpret as a failed step.
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest pytest-cov
- run: pytest --cov=myapp --cov-report=xml
- uses: codecov/codecov-action@v4 # Optional: upload to Codecov
with:
files: coverage.xml
FAILED (coverage: 73%, minimum: 80%)
Error: Process completed with exit code 2.
The --cov-report=xml flag generates a coverage.xml file compatible with Codecov, SonarCloud, and most CI dashboards. This lets you track coverage trends over time and get PR comments showing whether a change increased or decreased coverage. Start with a threshold that matches your current baseline and raise it gradually — trying to jump from 40% to 80% overnight usually leads to low-value tests written purely to hit the number.
Real-Life Example: Coverage for a Shopping Cart Module
This example shows a realistic use of coverage.py against a small shopping cart implementation, including the HTML report workflow and fixing the gaps it finds.
# cart.py
class Cart:
def __init__(self):
self.items = {}
def add(self, name, price, qty=1):
if qty <= 0:
raise ValueError("Quantity must be positive")
if name in self.items:
self.items[name]["qty"] += qty
else:
self.items[name] = {"price": price, "qty": qty}
def remove(self, name):
if name not in self.items:
raise KeyError(f"{name!r} is not in the cart")
del self.items[name]
def total(self):
return sum(v["price"] * v["qty"] for v in self.items.values())
def discount_total(self, pct):
if not 0 <= pct <= 100:
raise ValueError("Discount must be 0-100")
return self.total() * (1 - pct / 100)
# test_cart.py
import pytest
from cart import Cart
def test_add_and_total():
c = Cart()
c.add("apple", 0.50, 3)
c.add("banana", 0.30, 2)
assert c.total() == pytest.approx(2.10)
def test_add_existing_item():
c = Cart()
c.add("apple", 0.50, 2)
c.add("apple", 0.50, 1)
assert c.items["apple"]["qty"] == 3
def test_remove():
c = Cart()
c.add("apple", 0.50)
c.remove("apple")
assert "apple" not in c.items
def test_discount():
c = Cart()
c.add("shirt", 50.0)
assert c.discount_total(10) == pytest.approx(45.0)
pytest --cov=cart --cov-report=term-missing --cov-branch tests/
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------
cart.py 19 4 8 3 71% 9, 16, 22, 25
Coverage reveals four uncovered lines: the ValueError on invalid quantity (line 9), the KeyError on remove of nonexistent item (line 16), the invalid discount error (line 22), and one branch in discount_total. Adding tests for these edge cases raises coverage to 100% and -- more importantly -- actually verifies that the error handling works correctly. That is the real value: coverage.py does not just track lines, it points you toward the cases your test suite was ignoring.
Frequently Asked Questions
What is a good coverage percentage to aim for?
80% is the common industry target and a reasonable starting point for most projects. The right number depends on your project's risk tolerance: financial calculation libraries or authentication code warrant 90%+, while a utility script or prototype might be fine at 70%. More important than the percentage is whether your tests cover the critical paths and error conditions. 100% coverage does not mean 100% correctness -- it means every line ran, not that every edge case was verified.
How do I exclude files or lines from coverage measurement?
Use the omit setting in .coveragerc for whole files, and use the special comment # pragma: no cover to exclude individual lines or blocks. Common candidates for exclusion: if __name__ == "__main__": blocks, abstract methods that must be overridden, and defensive code that is genuinely impossible to trigger in tests. Use these sparingly -- over-exclusion makes your coverage number meaningless.
When should I use branch coverage instead of statement coverage?
Use branch coverage whenever your code has significant conditional logic: if/else chains, try/except blocks, or ternary expressions. Statement coverage will show 100% if every line ran at least once, but branch coverage will flag cases where you only tested one side of a conditional. Enable it from the start with branch = True in .coveragerc -- the stricter feedback catches real bugs earlier.
Does coverage.py slow down my tests?
Yes, by roughly 10-30% depending on project size. Coverage.py instruments your code by inserting trace hooks at the bytecode level, which adds overhead to every function call and line execution. For most projects this is completely acceptable. If you have a very large test suite where speed matters, run coverage only in CI (not on every local run) or use the --no-cov flag during rapid development cycles.
Does coverage work with parallel test execution?
Yes, but requires the pytest-xdist plugin and the --cov-append flag. When running tests in parallel with pytest -n auto, each worker writes a separate .coverage.* file. Run coverage combine after the parallel run to merge them, then coverage report to see the combined results. The .coveragerc entry parallel = True enables this automatically.
Why does coverage show lines as covered that I know aren't being tested?
This usually happens when a module is imported (not tested) and coverage.py counts the top-level module code (imports, class definitions, constants) as executed. It can also happen if shared fixtures or setup code in conftest.py inadvertently call your functions. Use --cov-report=annotate or the HTML report to inspect the exact execution context, and consider enabling branch coverage to distinguish "ran once in setup" from "thoroughly tested."
Conclusion
Coverage.py turns the abstract question "are my tests good enough?" into a concrete, measurable answer. You learned how to install pytest-cov and run coverage alongside your test suite, use --cov-report=term-missing to pinpoint uncovered lines in the terminal, generate an HTML report for detailed visual inspection, configure .coveragerc to set thresholds and omit irrelevant paths, and enable branch coverage to catch untested conditional logic.
The real habit to build is not chasing a coverage percentage but using the HTML report as a checklist after writing each new feature. Red lines are not failures -- they are a map of where your next tests should go. Extend the shopping cart example by adding tests for concurrent access, serialization, or currency rounding, and watch your coverage climb as your confidence in the code grows.
Full documentation is available at coverage.readthedocs.io and the pytest-cov plugin documentation at pytest-cov.readthedocs.io.