Skip to content
SP StackPractices
beginner By Mathias Paulenko

Measure Test Coverage with pytest-cov

How to measure and enforce Python test coverage thresholds with pytest-cov, including branch coverage, HTML reports, exclusions, and CI integration.

Topics: testing

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

pytest-cov is a pytest plugin that integrates the coverage.py library. It measures which lines of your Python code are executed during tests and reports the percentage. You can enforce minimum thresholds, generate HTML reports, and track branch coverage (if/else paths) — not just line coverage.

When to Use

  • Measuring how much of your codebase is covered by tests
  • Enforcing a minimum coverage threshold in CI (e.g., fail builds below 80%)
  • Identifying dead code that is never executed by any test
  • Generating HTML coverage reports for visual inspection of gaps
  • Tracking branch coverage to ensure both sides of if/else are tested

When NOT to Use

  • Chasing 100% coverage as a goal — 80-90% with good test quality is better than 100% with trivial tests
  • Measuring coverage for scripts or one-off utilities — focus on tested production code
  • Using coverage as the only quality metric — high coverage with bad assertions gives false confidence

Solution

Setup

pip install pytest pytest-cov

Basic coverage run

pytest --cov=myapp tests/

This prints a summary to the terminal:

---------- coverage: platform linux, python 3.12 ----------
Name                    Stmts   Miss  Cover
-------------------------------------------
myapp/__init__.py           2      0   100%
myapp/models.py            45      5    89%
myapp/services.py          80     12    85%
myapp/api.py               60      8    87%
-------------------------------------------
TOTAL                     187     25    87%

Enforce minimum coverage

pytest --cov=myapp --cov-fail-under=80 tests/

If coverage falls below 80%, pytest exits with a non-zero code, failing CI.

HTML report

pytest --cov=myapp --cov-report=html tests/

Opens htmlcov/index.html in a browser. Green lines are covered, red lines are missed, with line-by-line highlighting.

Branch coverage

pytest --cov=myapp --cov-branch --cov-report=term-missing tests/

Branch coverage checks that both the if and else paths of each conditional are executed. Line coverage alone can miss branches where the condition is always true or always false in tests.

Configuration in pyproject.toml

[tool.pytest.ini_options]
addopts = "--cov=myapp --cov-report=term-missing --cov-report=html --cov-branch"

[tool.coverage.run]
source = ["myapp"]
branch = true
omit = [
    "myapp/__init__.py",
    "myapp/migrations/*",
    "*/tests/*",
]

[tool.coverage.report]
show_missing = true
skip_covered = false
fail_under = 80
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]

Excluding specific lines

def get_config(key: str, default=None):
    if key in os.environ:
        return os.environ[key]
    return default  # pragma: no cover

The # pragma: no cover comment tells coverage to ignore that line.

Excluding entire blocks

if TYPE_CHECKING:
    from myapp.models import User  # pragma: no cover

With the exclude_lines config above, any line matching if TYPE_CHECKING: and everything under it is excluded.

Coverage for multiprocessing

# pyproject.toml
[tool.coverage.run]
concurrency = ["multiprocessing", "thread"]

Coverage with parallel test runs

pytest -n auto --cov=myapp --cov-report=term-missing

With pytest-xdist, each worker writes its own .coverage file. Use coverage combine before generating the report:

coverage combine
coverage report
coverage html

Variants

Using coverage.py directly (without pytest)

coverage run -m pytest tests/
coverage report -m
coverage html

Coverage diff with diff-cover

pip install diff-cover
coverage xml
diff-cover coverage.xml --compare-branch=origin/main --html-report=coverage-diff.html

This shows coverage only for lines changed in the current branch — useful for PR reviews.

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

Generates an SVG badge with the current coverage percentage for your README.

Best Practices

  • For a deeper guide, see Pytest in Production Guide.

  • Set a realistic threshold (80-85%) — too high encourages trivial tests just to hit the number

  • Use branch coverage alongside line coverage — it catches untested else paths

  • Exclude migration files, __init__.py, and test files from coverage measurement

  • Use # pragma: no cover for debug-only code, __repr__, and if __name__ blocks

  • Review coverage reports regularly — find gaps in critical paths, not just overall percentage

  • Generate XML coverage for CI tools (SonarQube, Codecov, Coveralls)

Common Mistakes

  • Chasing 100% coverage: writing trivial tests (assert True) to cover lines without verifying behavior.
  • Not using branch coverage: line coverage of 100% can still miss else branches.
  • Including test files in coverage: tests/ should be excluded — you’re measuring production code.
  • Not combining parallel coverage files: with pytest-xdist, each worker writes a separate file. Run coverage combine before reporting.
  • Excluding too much: if you exclude every hard-to-test line, the number becomes meaningless.

FAQ

What is the difference between line coverage and branch coverage?

Line coverage measures whether a line was executed. Branch coverage measures whether both paths of a conditional (if/else) were taken. A line with if x: can be 100% line-covered but only 50% branch-covered if x is always True in tests.

How do I exclude a whole file from coverage?

In pyproject.toml:

[tool.coverage.run]
omit = ["myapp/legacy/*", "myapp/migrations/*"]

How do I get coverage for a single test file?

pytest tests/test_models.py --cov=myapp.models --cov-report=term-missing

Can I use pytest-cov with Django or Flask?

Yes. Point --cov to your project package:

pytest --cov=myproject --cov-report=html

For Django, ensure DJANGO_SETTINGS_MODULE is set in your test configuration.

How do I fail CI only on decreased coverage?

Use diff-cover with --fail-under=100 to require 100% coverage on changed lines:

coverage xml
diff-cover coverage.xml --compare-branch=origin/main --fail-under=100

How do I exclude lines from coverage?

Add # pragma: no cover to exclude single lines. Use # pragma: no cover <reason> for documentation. Configure exclusions in .coveragerc:

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise NotImplementedError
    if __name__ == .__main__.:

Exclude debug-only code, repr methods, and abstract method stubs. Do not exclude error handling paths — those are critical to test.

How do I measure branch coverage instead of line coverage?

Pass --cov-branch to pytest-cov or set branch = True in .coveragerc:

pytest --cov=myapp --cov-branch --cov-report=term-missing

Branch coverage reports whether both the true and false paths of each conditional were executed. It catches missing else branches and short-circuit evaluation paths that line coverage misses.

How do I generate coverage badges for my README?

Use coverage-badge to generate SVG badges from your coverage report:

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

Add the badge to your README: ![coverage](coverage.svg). In CI, generate the badge as an artifact and commit it to a badges branch or upload to a badge service like shields.io.

How do I handle coverage with multiprocessing?

Use coverage with --concurrency=multiprocessing in .coveragerc:

[run]
concurrency = multiprocessing
parallel = True

This spawns separate coverage data files per process. Run coverage combine after the test suite to merge them. Without this, coverage from child processes is lost.

How do I integrate coverage with GitHub Actions?

Add a step in your workflow to run pytest with coverage and upload the report:

- run: pytest --cov=myapp --cov-report=xml
- uses: codecov/codecov-action@v4
  with:
    file: ./coverage.xml

Codecov posts a comment on PRs with coverage diff and visualizes uncovered lines. Use fail_under in .coveragerc to fail the CI job if coverage drops below a threshold.