Coverage.py: Python Code Coverage from Setup to CI Integration
Coverage.py measures which Python lines, branches, and functions are executed during tests. Use it with pytest-cov for seamless pytest integration, enforce minimum coverage with --fail-under, and generate XML reports for Codecov, SonarQube, and GitHub Actions integrations.
Coverage.py is the standard Python coverage measurement tool. Unlike language-specific instrumentation (JaCoCo bytecode), Coverage.py uses Python's sys.settrace() hook to intercept every line execution. It's fast, accurate, and integrates directly with pytest through pytest-cov.
Installation
pip install coverage pytest-covBasic Usage
Running with pytest-cov
# Coverage for the 'myapp' package
pytest --cov=myapp tests/
# Show missing lines in terminal
pytest --cov=myapp --cov-report=term-missing tests/
# Generate HTML report
pytest --cov=myapp --cov-report=html tests/
# Open: htmlcov/index.htmlFail on Low Coverage
# Fail if coverage drops below 80%
pytest --cov=myapp --cov-fail-under=80 tests/The exit code is 2 when coverage falls below the threshold—different from test failure (exit code 1), so you can distinguish them in CI.
Configuration with .coveragerc
Avoid repeating flags on every command:
# .coveragerc
[run]
source = myapp
branch = True # Enable branch coverage
omit =
*/migrations/*
*/tests/*
*/conftest.py
*/__init__.py
*/manage.py # Django management commands
[report]
precision = 2
show_missing = True
skip_covered = False
fail_under = 80
[html]
directory = htmlcov
title = MyApp Coverage Report
[xml]
output = coverage.xmlWith this file in your project root, just run:
pytest --cov --cov-report=html --cov-report=xmlpyproject.toml Configuration
Modern Python projects prefer pyproject.toml:
[tool.coverage.run]
source = ["myapp"]
branch = true
omit = [
"*/migrations/*",
"*/tests/*",
"*/conftest.py",
]
[tool.coverage.report]
precision = 2
show_missing = true
fail_under = 80
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@(abc\\.)?abstractmethod",
]
[tool.coverage.html]
directory = "htmlcov"
[tool.coverage.xml]
output = "coverage.xml"Branch Coverage
Line coverage misses logical branches. Enable branch coverage to find untested conditions:
def process_order(order):
if order.status == "pending": # ← branch 1: status == pending
validate(order)
if order.total > 1000: # ← branch 2a: total > 1000
apply_discount(order) # ← branch 2b: total <= 1000 (never tested?)
else: # ← branch 1b: status != pending
raise ValueError("Invalid status")
return orderWith branch = True, Coverage.py tracks each conditional path. The HTML report highlights partial branches in yellow.
pytest --cov=myapp --cov-branch --cov-report=html tests/Excluding Code
Some code shouldn't count toward coverage: abstract methods, type-checking blocks, platform-specific code.
Pragma Comments
def __repr__(self):
return f"Order({self.id})" # pragma: no cover
class AbstractProcessor:
def process(self):
raise NotImplementedError # pragma: no cover
if TYPE_CHECKING: # pragma: no cover
from myapp.types import Config
# Platform-specific code
if sys.platform == "win32": # pragma: no cover
def get_temp_dir():
return os.environ["TEMP"]Pattern-Based Exclusions in Config
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"def __str__",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"pass",
"@(abc\\.)?abstractmethod",
"\\.\\.\\.", # Ellipsis (Protocol stubs)
]
exclude_also = [
# Exclude entire files matching patterns
]Combining Coverage from Multiple Test Runs
For projects with separate unit and integration test suites:
# Run unit tests
pytest tests/unit/ --cov=myapp --cov-report= --cov-append
# Run integration tests (appends to existing .coverage)
pytest tests/integration/ --cov=myapp --cov-report= --cov-append
# Generate combined report
coverage report --show-missing
coverage htmlThe --cov-append flag adds to the existing .coverage file instead of replacing it.
Parallel Test Runs
When running tests in parallel (with pytest-xdist), Coverage.py needs coverage-enable-subprocess:
pip install pytest-cov coverage-enable-subprocess# .coveragerc
[run]
concurrency = multiprocessing # or 'thread' for threading-based parallelism
parallel = TrueAfter parallel tests:
coverage combine # Merge parallel .coverage.* files
coverage report
coverage htmlDjango Integration
Django projects need Coverage.py to instrument code loaded by Django's app registry:
# .coveragerc
[run]
source = .
omit =
*/migrations/*
manage.py
*/wsgi.py
*/asgi.py
*/settings*.py
*/urls.py # Often no logic to test# Run Django tests with coverage
coverage run manage.py test
coverage report
# Or with pytest-django
pytest --cov=myapp --cov-report=html tests/GitHub Actions Integration
name: Tests with Coverage
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Run tests with coverage
run: |
pytest \
--cov=myapp \
--cov-branch \
--cov-report=xml \
--cov-report=html \
--cov-fail-under=80 \
tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: always()
with:
file: coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Upload HTML report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: htmlcov/Reading HTML Reports
The Coverage.py HTML report shows:
- Overview: Total coverage percentage, missing lines count, per-file summary
- File view: Source code highlighted green (covered), red (missed), yellow (partial branch)
- Branch arrows: Click a partially-covered line to see which branches were taken
Green lines are fully covered. Red lines were never executed. Yellow lines were executed but not all branches were taken (e.g., an if statement where only the True branch was tested).
Coverage Badges
Add a coverage badge to your README via Codecov or Coveralls:
[](https://codecov.io/gh/yourorg/yourrepo)Or generate locally with coverage-badge:
pip install coverage-badge
coverage-badge -o coverage.svgInterpreting Coverage Numbers
What 80% line coverage means: 80% of your source lines are executed by at least one test. The other 20% include untested edge cases, error handlers, and branches.
What it doesn't mean: High coverage doesn't equal well-tested code. A test that calls every function but asserts nothing has 100% coverage and proves nothing. Coverage measures execution, not correctness.
Diminishing returns: Going from 60% to 80% coverage often catches real gaps. Going from 90% to 95% usually means testing trivial getters or configurations. Set thresholds that reflect business risk, not arbitrary benchmarks.
Track trends: A coverage metric is most useful as a trend. If coverage drops from 85% to 79% after a PR, the PR likely added untested code. Fail on drops rather than enforcing a fixed threshold—this incentivizes adding tests alongside new features.
Summary
Coverage.py with pytest-cov is the standard Python coverage stack. Configure via .coveragerc or pyproject.toml with branch = True for meaningful coverage data, set fail_under to enforce minimums in CI, and generate XML for Codecov integration. The HTML report's per-file, per-line visualization makes it easy to identify exactly which code paths lack tests—and more importantly, whether those paths represent real risk or just uncritical boilerplate.