Coverage.py: Python Code Coverage from Setup to CI Integration

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-cov

Basic 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.html

Fail 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.xml

With this file in your project root, just run:

pytest --cov --cov-report=html --cov-report=xml

pyproject.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 order

With 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 html

The --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 = True

After parallel tests:

coverage combine    # Merge parallel .coverage.* files
coverage report
coverage html

Django 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:

[![codecov](https://codecov.io/gh/yourorg/yourrepo/branch/main/graph/badge.svg)](https://codecov.io/gh/yourorg/yourrepo)

Or generate locally with coverage-badge:

pip install coverage-badge
coverage-badge -o coverage.svg

Interpreting 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.

Read more

Start now free