backend / testing / pytest / 08_coverage_branches.md

Coverage and branches

4 min read source

Coverage and branches

Code coverage measures which lines (or branches) of your code ran during the test suite. High coverage isn’t a goal in itself — it’s a leading indicator of “tested at least once,” not “tested correctly.”

pytest-cov

pip install pytest-cov
pytest --cov=myapp --cov-report=term-missing --cov-report=html
  • --cov=myapp — measure the myapp package (omit to measure everything).
  • --cov-report=term-missing — table with file, %coverage, missing lines.
  • --cov-report=html — generates htmlcov/index.html (clickable, line-by-line).
  • --cov-report=xml — for CI integrations (Codecov, Coveralls).

Line vs branch coverage

def classify(x):
    if x > 0:
        return "positive"
    return "non-positive"
def test_classify():
    assert classify(5) == "positive"
  • Line coverage: 100%? No — line 3 (return "non-positive") wasn’t hit. Reported as 75%.
  • After adding assert classify(-1) == "non-positive": line coverage 100%.
  • Branch coverage: also 100% — both arms of the if exercised.

But:

def classify(x, log=False):
    if x > 0:
        if log:
            print("positive")
        return "positive"
    return "non-positive"
def test_classify():
    assert classify(5) == "positive"     # line 2, 4 hit; line 3 (print) missed
    assert classify(-1) == "non-positive"
  • Line coverage: missing line 3 — 80% line coverage.
  • Branch coverage: missed both log=True paths — much lower in branch %.

Enable branch coverage:

pytest --cov=myapp --cov-branch

Branch coverage catches conditionals where you only tested one of multiple paths. Always-on for any project beyond toy size.

What 100% coverage doesn’t prove

A test suite at 100% coverage can still:

  • Miss bugs in cases between the example values you picked.
  • Have wrong assertions (“test passes” doesn’t mean “behavior is correct”).
  • Miss edge cases coverage tooling can’t see — race conditions, integer overflow, locale-specific issues.
  • Pass when the system under test is wrong but consistent (test mirrors implementation).

Coverage measures “test reaches code.” It says nothing about whether the test would fail if the code were broken. Mutation testing (e.g., mutmut, cosmic-ray) does that — flip an operator in the code and check if any test fails.

Excluding code from coverage

Some code legitimately can’t be reached in tests (e.g., if __name__ == "__main__": blocks, Python-version-specific branches).

def fetch():
    try:
        return cache.get("key")
    except CacheUnavailable:  # pragma: no cover
        return None

# pragma: no cover excludes the line/block.

For systematic exclusions, use .coveragerc or pyproject.toml:

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
    "if __name__ == .__main__.:",
    "@(abc\\.)?abstractmethod",
]

Configuration

# pyproject.toml
[tool.coverage.run]
source = ["myapp"]
branch = true
omit = ["*/tests/*", "*/migrations/*"]

[tool.coverage.report]
fail_under = 80          # fail if total coverage < 80%
show_missing = true
skip_covered = true      # hide files at 100% in the terminal output

[tool.coverage.html]
directory = "htmlcov"

fail_under is the killer feature for CI: enforces a floor without requiring discipline.

CI integration

# .github/workflows/test.yml
- run: pytest --cov=myapp --cov-report=xml --cov-fail-under=80
- uses: codecov/codecov-action@v3
  with:
    files: coverage.xml

In Codecov / Coveralls dashboards, you see PR-level diff coverage — much more useful than total %. “This PR added 100 lines, 90 are covered” matters more than “the project is at 87.3%.”

Coverage-driven workflow

  1. Write feature.
  2. Run pytest --cov=myapp --cov-report=term-missing.
  3. Look at the missing-lines column.
  4. Decide for each: write a test, mark # pragma: no cover, or accept the gap.

Don’t chase 100%. Chase “every meaningful path is tested.” 85–95% is typical for healthy codebases.

What to test beyond coverage

Things 100% coverage doesn’t give you, that you still need:

  • Property tests (see 07_hypothesis_property_testing.md) — random inputs reveal cases your examples miss.
  • Integration tests — components together, real DB / API. Coverage tools count these too.
  • Contract tests — your service vs. its dependencies’ actual behavior.
  • Mutation tests — flip operators in source, check if any test fails. The real coverage signal.
  • Performance tests — coverage doesn’t see “this is 100× slower than acceptable.”

Common pitfalls

  • Coverage drops on a refactor and the team panics — usually a configuration issue (test moved, source path wrong). Check --cov paths.
  • “Coverage went up after deletion!” — yes, deleting untested code raises the percentage. Doesn’t mean the codebase is healthier.
  • Test code itself counts — if tests/ isn’t in omit, you’ll see tests/test_foo.py at 100% (because it ran), padding the average. Always omit tests.
  • Branch coverage on modern Python features — match-case, walrus, comprehensions can confuse older coverage versions. Update.

Interview angle

  • Q: “What’s the difference between line and branch coverage?” — line: did this line execute? Branch: were both arms of every if exercised?
  • Q: “What does 100% coverage prove?” — that every line ran. Not that behavior is correct.
  • Follow-up: “How do you exclude lines from coverage?” — # pragma: no cover per-line, exclude_lines in config for patterns.
  • Follow-up: “What’s mutation testing and why is it stronger than coverage?” — flips operators in source; if tests still pass, the test was inadequate even though coverage was 100%.

See 07_hypothesis_property_testing.md, 09_test_doubles_taxonomy.md.