Module 5: Coverage and Edge Cases

pytest-cov and Coverage Reports

pytest-cov and Coverage Reports

Capsule overview

Your tests pass. But how much of your code do they actually run? Without measurement, you're flying blind. A test suite that "passes" may be running only 30% of the code — leaving the other 70% as unknown territory where the bugs hide.

This capsule introduces you to pytest-cov, the tool that measures what percentage of your code runs during the tests. You'll learn to install it, run it, read the terminal and HTML reports, configure it properly, and use a complete example (a calculator) to identify coverage gaps.

By the end, you'll have the map you need to know which tests you're missing. The next step — interpreting that map — you'll see in capsule 03.

Before you start

You need Python 3.8+ and pytest installed. If you're coming from Module 4, you already have a project with tests. If not, create a directory with a minimal module and a test that imports it — enough to run pytest --cov.


What Code Coverage Is

It isn't a measure of quality

Code coverage is the percentage of lines (or branches, or functions) of your code that run when you execute your tests. That's all. It doesn't measure whether your code is correct, whether your tests are good, or whether your feature works in production.

High coverage ≠ Quality code
100% coverage ≠ No bugs

A test that does assert True in every function gives 100% coverage. But it doesn't validate anything. Coverage tells you where your tests go, not how well they validate.

An analogy: the map

Think of coverage as a map of your code:

  • ✅ Green (covered): Your tests go through there. You know what that code does during the tests.
  • ❌ Red (missing): Your tests never go in there. You have no idea what happens when that path runs.
  • ⚠️ Yellow (partial): You cover one branch of the if but not the other. There are unexplored logical paths.

The map doesn't tell you whether the territory is safe. But without the map, you don't even know what territory exists. Coverage is visibility, not a guarantee.

Why it matters when you work with AI

Claude Code can generate 200 lines in seconds. Without coverage, you have no objective way to know what fraction of those 200 lines is validated. You can have 50 tests that pass and cover only the happy path — leaving all the error logic, validations and edge cases untouched. Coverage gives you the concrete number and the exact lines that are missing.

What coverage doesn't tell you

It's important to be clear about the limitations:

  • Coverage doesn't validate logic. A line can run with a trivial input and your assert can be incorrect. For example: assert add(2, 2) == 4 — the line runs, but if you had written assert add(2, 2) == 5 by mistake, the test would fail. Coverage only measures execution, not correctness.
  • Coverage doesn't prioritize. All lines count the same. A line in the critical authentication path and one in a debug print add up equally to the percentage. Your judgment is still needed to decide which gaps to close first.
  • 100% isn't a blind goal. Some lines are impossible to cover (CLI configuration, if __name__ == "__main__"), others aren't worth it (defensive code that "should never" run). Capsule 03 goes deeper into this.

Before you start: requirements

To follow this capsule you need:

  • ✅ Python 3.8+ installed
  • ✅ A project with pytest configured (Modules 1-4)
  • ✅ A typical structure: a source code folder (src, app, etc.) and a tests/ folder

If your project uses src/ as a package, make sure you have a pyproject.toml with [tool.setuptools.packages.find] or equivalent, or that you run pytest from the root with the correct path.


Installation and Basic Usage

Installing pytest-cov

pip install pytest-cov

In a project with pyproject.toml or requirements.txt:

# requirements.txt
pytest>=7.0.0
pytest-cov>=4.0.0

Running coverage

By default, pytest-cov uses the coverage.py library to measure which code runs. You need to indicate which module(s) you want to measure:

# Measure the "mymodule" module while running the tests in tests/
pytest --cov=mymodule tests/ -v

# Measure several modules
pytest --cov=src --cov=mymodule tests/ -v

The --cov flag indicates the source to measure. It's usually your project's main package (src, app, mymodule, etc.).

Report formats

Terminal with the missing lines:

pytest --cov=mymodule --cov-report=term-missing tests/

This shows in the terminal exactly which lines aren't covered (for example: 18-22, 31-35).

HTML report (interactive):

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

It generates the htmlcov/ folder. Open htmlcov/index.html in your browser to see the report with colors: green = covered, red = not covered. Click on any file to see it line by line.

Both at once:

pytest --cov=mymodule --cov-report=term-missing --cov-report=html tests/

Reading the Terminal Report

An example of the output

Name               Stmts   Miss  Cover   Missing
-------------------------------------------------
mymodule/calc.py      25      5    80%   18-22
mymodule/utils.py     40     12    70%   31-35, 38-44
-------------------------------------------------
TOTAL                 65     17    74%

What each column means

ColumnMeaning
NameThe file or module measured
StmtsThe number of executable statements (lines of code that can run)
MissStatements that no test ran
CoverThe percentage: (Stmts - Miss) / Stmts
MissingThe exact line numbers that weren't covered

How to use the Missing column

The Missing column is the most useful one for improving your suite. If you see 18-22, open the file, go to those lines, and ask: "what test scenario would make the flow get here?" Sometimes it's dead code (never used). Sometimes it's an error branch you never tested.

A practical reading example

Imagine the report shows:

src/auth.py    42   12   71%   23-28, 35-40

Concrete steps:

  1. You open src/auth.py.
  2. Lines 23-28: you see an if not user: that raises UnauthorizedError.
  3. Lines 35-40: you see an except ValidationError that returns a specific message.
  4. Conclusion: you don't have tests that simulate a nonexistent user or a failed validation. Those are your next tests.

The HTML Report

Generating and opening it

pytest --cov=mymodule --cov-report=html tests/
open htmlcov/index.html   # macOS
# or: xdg-open htmlcov/index.html  (Linux)
# or: start htmlcov/index.html    (Windows)

What you'll see

  • The main page: A list of files with their coverage percentage.
  • Green: Lines run by at least one test.
  • Red: Lines never run.
  • Clicking a file: A line-by-line view with colors. Red = a gap.

The HTML report is ideal for exploring large codebases: you can jump between files and see exactly which if/else or try/except branches aren't covered.

When to use HTML vs the terminal

SituationRecommendation
CI/CD, a quick committerm-missing — fast, in the same output as pytest
Deep analysis, new codeHTML — visual exploration
A code review of coverageHTML — share htmlcov/ with the team
Debugging a specific fileBoth — the terminal for Missing, HTML for context

Types of Coverage

Coverage isn't a single number. There are several dimensions:

Line coverage (the default)

The question: Did this line run at least once?

It's the most common one. A covered line means some test ran it. It doesn't say whether you covered every logical branch.

Branch coverage

The question: Did both branches of each if, else, try/except, etc. run?

An example:

def classify(x: int) -> str:
    if x >= 0:
        return "positive"
    else:
        return "negative"

If you only test classify(5), you have 100% line coverage but 50% branch coverage: the else branch never runs. Branch coverage is more valuable because it covers logical paths, not just lines.

To enable it:

pytest --cov=mymodule --cov-branch tests/

Function coverage

The question: Was this function called at least once?

It's usually redundant with line coverage (if a function ran, its lines were covered). But in detailed reports it can be useful to see which functions are never invoked.

A practical summary

Line coverage:   Did the flow go through this line?
Branch coverage: Did it go through both branches of the if/else?
Function coverage: Was this function called?

Prioritize branch coverage when you can — it catches bugs in error branches that line coverage ignores.

Using pytest-cov with Claude Code

When you work with Claude Code, coverage becomes a collaboration tool:

  1. Measure after each feature: Run pytest --cov=src --cov-report=term-missing tests/ after implementing. Share the output (or the Missing column) with Claude Code with the prompt: "These lines aren't covered. Generate tests that cover them."

  2. Give the report's context: Instead of asking "write tests", say something like: "The coverage report shows Missing: 23-28 in auth.py. Those lines are the branch that raises UnauthorizedError when the user doesn't exist. Write tests that cover that case."

  3. Use HTML for deep analysis: If you have a large module with many gaps, generate the HTML and open htmlcov/index.html. Then ask Claude Code: "I have this module [paste the code]. The coverage report shows that lines 45-67 aren't covered. What tests should I write?" Claude Code can analyze the code and the lines' context to propose relevant tests.

  4. Iterate with the report: The ideal workflow is: measure → give the report to Claude Code → generate tests → measure again. Each iteration reduces the gaps until you reach your target (for example 90%).


Configuration with pyproject.toml or .coveragerc

pyproject.toml

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

[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/__pycache__/*", "*/venv/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError",
]
  • addopts: The options pytest runs by default (including coverage).
  • source: Which folders to measure.
  • omit: Files or patterns to exclude (tests, cache, venv).
  • exclude_lines: Lines that coverage will ignore (useful for __repr__, defensive code that "never" runs, etc.).

With this, just running pytest tests/ gives you coverage.

.coveragerc (an alternative)

If you prefer a dedicated file:

[run]
source = src
omit = tests/*, */__pycache__/*, */venv/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise NotImplementedError

A Complete Example: A Calculator

The project structure

calc_demo/
├── src/
│   └── calc.py
├── tests/
│   └── test_calc.py
├── pyproject.toml
└── htmlcov/          # generated by pytest-cov

The module to measure: calc.py

# src/calc.py
"""A basic calculator with arithmetic operations."""

def add(a: float, b: float) -> float:
    """Adds two numbers."""
    return a + b

def subtract(a: float, b: float) -> float:
    """Subtracts b from a."""
    return a - b

def multiply(a: float, b: float) -> float:
    """Multiplies two numbers."""
    return a * b

def divide(a: float, b: float) -> float:
    """Divides a by b. Raises ZeroDivisionError if b is 0."""
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

Incomplete tests (only add and subtract)

# tests/test_calc.py
import pytest
from src.calc import add, subtract

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -2) == -3

def test_subtract():
    assert subtract(10, 4) == 6

Running coverage

cd calc_demo
pip install pytest pytest-cov
pytest --cov=src --cov-report=term-missing tests/ -v

The expected output

Name           Stmts   Miss  Cover   Missing
---------------------------------------------
src/calc.py       15      8    47%   14-21
---------------------------------------------
TOTAL             15      8    47%

Lines 14-21 correspond to multiply and divide. The report tells you exactly which functions no test is running.

How to use this information

  1. You look at Missing: 14-21.
  2. You open src/calc.py and see that they're multiply and divide.
  3. You write tests for those functions (including the divide(x, 0) case to cover the if b == 0 branch).
  4. You run coverage again. The percentage goes up and Missing shrinks or disappears for that file.

A walkthrough: from the report to the tests

Let's say you ran coverage and got:

Name           Stmts   Miss  Cover   Missing
---------------------------------------------
src/calc.py       15      8    47%   14-21
---------------------------------------------
TOTAL             15      8    47%

Step 1: Open src/calc.py and look for lines 14-21.

Step 2: You identify that they're multiply and divide (including the if b == 0).

Step 3: You decide which tests to write:

  • For multiply: a test with positive numbers and another with negative ones (just in case).
  • For divide: a normal test and one that verifies ZeroDivisionError.

Step 4: You write the tests (or ask Claude Code to generate them with the report's context).

Step 5: You run pytest --cov=src --cov-report=term-missing tests/ again.

The expected result: Coverage goes up to ~93% or 100%, and Missing is empty or disappears for calc.py.

This loop — measure, identify, write, measure — is the core of this module's project.

Using Claude Code to close gaps

When you have the report with Missing identified, you can give Claude Code the context so it generates the tests. An effective prompt:

I have this src/calc.py module. The coverage report indicates that lines 14-21
(multiply and divide) aren't covered. Write the missing tests for
test_calc.py, including the divide-by-zero case that raises ZeroDivisionError.

Claude Code generates the tests, you run them, and the coverage goes up. In capsule 04 you'll learn more sophisticated prompts for edge cases.


A Quick Command Reference

GoalCommand
Basic coveragepytest --cov=src tests/
See the missing linespytest --cov=src --cov-report=term-missing tests/
An HTML reportpytest --cov=src --cov-report=html tests/
Include branch coveragepytest --cov=src --cov-branch tests/
All togetherpytest --cov=src --cov-report=term-missing --cov-report=html --cov-branch tests/
Just one filepytest tests/test_calc.py --cov=src.calc --cov-report=term-missing

Using pytest-cov with Claude Code

When you work with Claude Code to improve coverage, the report is your ally. Follow these patterns:

Give the report as context

Before asking for tests to close gaps, pass the coverage output:

Here's the coverage report for my auth module:

Name           Stmts   Miss  Cover   Missing
---------------------------------------------
src/auth.py      52     18    65%   23-28, 35-40, 67-72
---------------------------------------------
TOTAL            52     18    65%

Lines 23-28 are the user-not-found branch.
Lines 35-40 are the ValidationError handling.
Lines 67-72 are the logout.

Generate tests that cover these lines.

The more specific you are (line numbers, what each block does), the more precise the tests Claude Code generates will be.

Iterate with the updated report

After adding tests, run coverage again and share the new report. If there are still gaps, ask for tests for the remaining lines. The cycle is:

  1. Run pytest --cov=src --cov-report=term-missing tests/
  2. Copy the output and paste it into the chat with Claude Code
  3. Ask for tests for the lines in Missing
  4. Repeat until you reach the target

The HTML report as a visual reference

If the project is large, generate the HTML and open calc.py (or the file you're interested in). Describe to Claude Code which lines are red: "In calc.py, lines 14-21 (multiply and divide) are uncovered." Claude Code can't see the image, but with that description it can generate the right tests.


Exercises

Exercise 1: Install and run (Basic)

Create an exercise_01 directory with this structure:

exercise_01/
├── greet.py
└── tests/
    └── test_greet.py

The greet.py module should have a greet(name: str) -> str function that returns f"Hello, {name}!". Write a test that calls greet("World"). From exercise_01, run pytest --cov=greet --cov-report=term-missing tests/ -v and note the percentage you get.

See solution
# exercise_01/greet.py
def greet(name: str) -> str:
    return f"Hello, {name}!"
# exercise_01/tests/test_greet.py
from greet import greet

def test_greet():
    assert greet("World") == "Hello, World!"
pip install pytest pytest-cov
pytest --cov=greet --cov-report=term-missing tests/ -v

You should see 100% coverage in greet.py because the only executable line (the return) runs in the test. If the percentage is lower, check that the import is correct and that you're running from the exercise_01 directory.

Exercise 2: Interpret Missing (Intermediate)

You have this module:

# validator.py
def is_even(n: int) -> bool:
    if n % 2 == 0:
        return True
    return False

And this test:

def test_is_even():
    assert is_even(4) is True

Run coverage. Which lines appear in Missing? Why?

See solution

The lines in Missing will be the return False ones (for example, line 5). The test only goes through the True branch (when n % 2 == 0). The False branch never runs. To cover it, you need a test like:

def test_is_even_false():
    assert is_even(3) is False

Exercise 3: Configure pyproject.toml (Intermediate)

Add coverage configuration to an existing pyproject.toml so that, by running just pytest, coverage runs with term-missing and --cov-branch, measuring the app package and excluding tests/ and venv/.

See solution
[tool.pytest.ini_options]
addopts = "--cov=app --cov-report=term-missing --cov-branch"

[tool.coverage.run]
source = ["app"]
omit = ["tests/*", "*/venv/*", "*/__pycache__/*"]

Exercise 4: Identify gaps with HTML (Intermediate)

Generate an HTML report for the calculator project (src/calc.py with add, subtract, multiply, divide). Open htmlcov/index.html, go into calc.py, and note which lines are red. Write the missing tests so they disappear.

See solution

The red lines will be multiply and divide, plus the if b == 0 branch inside divide. The necessary tests:

def test_multiply():
    assert multiply(3, 4) == 12

def test_divide():
    assert divide(10, 2) == 5.0

def test_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"):
        divide(5, 0)

Exercise 5: Branch vs Line coverage (Advanced)

This code has a bug in the else branch:

# buggy.py
def clamp(value: int, low: int, high: int) -> int:
    if value < low:
        return low
    elif value > high:
        return high
    else:
        return value

Write a test that passes clamp(5, 0, 10) (a value in range). Run coverage with --cov-branch and without it. Explain why line coverage can show 100% but branch coverage can't.

See solution

With a single clamp(5, 0, 10) test:

  • Line coverage: 100% — every line runs (the flow goes in through else).
  • Branch coverage: ~50% or less — you don't cover the value < low or value > high branches.

If you had a bug in the elif value > high branch (for example return low instead of return high), line coverage wouldn't detect it because that branch never runs. Branch coverage forces you to write tests for every branch.

The complete tests:

def test_clamp_below_low():
    assert clamp(-5, 0, 10) == 0

def test_clamp_above_high():
    assert clamp(15, 0, 10) == 10

def test_clamp_in_range():
    assert clamp(5, 0, 10) == 5

Exercise 6: Omit and exclude (Advanced)

You have an __init__.py that only does from .calc import * and a main() that's never tested (it's the CLI entry point). Configure coverage to omit __init__.py and to exclude lines with pragma: no cover so main() doesn't count in the report.

See solution

In pyproject.toml:

[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/__init__.py", "*/__pycache__/*"]

For main(), in the code:

def main():  # pragma: no cover
    """CLI entry point - not tested."""
    ...

And in the configuration:

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
]

pragma: no cover is a special comment that coverage.py recognizes to exclude lines from the report.


Guided practice: reproducing the complete example

If you want to follow the calculator example step by step, do the following:

1. Create the structure:

mkdir -p calc_demo/src calc_demo/tests
cd calc_demo

2. Create src/calc.py with the module's code (add, subtract, multiply, divide) exactly as shown above.

3. Create an empty src/__init__.py (or with from .calc import *) so src is a package.

4. Create tests/test_calc.py with only the add and subtract tests.

5. Create a minimal pyproject.toml:

[project]
name = "calc-demo"
version = "0.1.0"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

6. Run it:

pip install pytest pytest-cov
pytest --cov=src --cov-report=term-missing tests/ -v

You should see ~47% coverage and Missing: 14-21 (or similar ranges depending on the actual numbering). Then add the multiply and divide tests, run it again, and you'll see the percentage go up.

7. Generate the HTML and explore:

pytest --cov=src --cov-report=html tests/
open htmlcov/index.html

Go into src_calc_py.html (the name may vary) and observe the multiply and divide lines in red. This is the view you'll use in real projects to prioritize which gaps to close first.


Troubleshooting

"No data to report" or 0% coverage

Cause: The --cov path doesn't match what you import in the tests.

Solution: If you import from src.calc import add, the source must be src (the package), not src.calc or calc. Run from the project root and use --cov=src if your structure is src/calc.py.

Coverage includes tests and venv

Cause: You didn't configure omit.

Solution: In pyproject.toml or .coveragerc:

[tool.coverage.run]
omit = ["tests/*", "*/venv/*", "*/__pycache__/*"]

Branch coverage doesn't appear in the report

Cause: You didn't pass --cov-branch.

Solution:

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

Or in addopts:

addopts = "--cov=src --cov-branch --cov-report=term-missing"

The HTML isn't generated or doesn't update

Cause: An htmlcov/ folder from previous runs.

Solution: Delete htmlcov/ and run it again, or use --cov-report=html without manually modifying the files. Make sure you're in the correct directory (the project root).

Very low coverage in a file you did test

Cause: A possible relative vs absolute import issue, or the module is imported under a different name.

Solution: Verify that the tests import from the same path coverage is measuring. If you use src as a package, install it in editable mode (pip install -e .) or configure PYTHONPATH so the imports resolve correctly.

The report shows files that shouldn't be there (setup, conftest)

Cause: The source includes folders you didn't want to measure, or omit isn't well defined.

Solution: Adjust omit in pyproject.toml:

[tool.coverage.run]
omit = [
    "tests/*",
    "*/conftest.py",
    "*/__pycache__/*",
    "setup.py",
]

If conftest.py is in tests/, tests/* already covers it. If it's in the root, add it explicitly.


Tips for integrating pytest-cov into your flow

In local development

Run coverage when you finish a feature or before a commit. You don't need coverage on every save — it would be slow. A good moment: after your tests pass and before you push.

In pair programming with Claude Code

When Claude Code generates new code, ask it to run coverage: "run pytest with coverage and tell me which lines are missing". That way you validate that the implementation is covered from the start.

Prioritize gaps by impact

Not all uncovered lines are equal. Prioritize:

  • Critical business logic (validations, calculations)
  • Error branches (exceptions, fallbacks)
  • Recent code that had no tests

Leave for later: __repr__, debug prints, legacy code that "never fails".


Project Connection

This module's project is A suite with 90%+ coverage: you receive existing code (with no tests or with minimal tests) and you must reach ≥90% coverage. The first step of that workflow is measuring the current state — and that's exactly what pytest-cov lets you do.

A typical flow:

  1. You clone or open the existing code.
  2. You run pytest --cov=src --cov-report=term-missing tests/.
  3. You see the report: for example 35% coverage, with Missing indicating lines 45-60, 120-135, etc.
  4. You identify the most critical gaps (business logic, error handling).
  5. You use Claude Code to generate tests that cover those gaps (the following capsules).
  6. You measure again. You repeat until ≥90%.

Without pytest-cov you wouldn't have the map. With it, you know exactly which tests to write.


Summary

  • ✅ Coverage measures what percentage of your code runs during the tests — it's visibility, not quality.
  • ✅ pip install pytest-cov + pytest --cov=mymodule --cov-report=term-missing tests/ to get started.
  • ✅ --cov-report=html generates htmlcov/index.html for visual exploration.
  • ✅ Stmts, Miss, Cover, Missing: use Missing to identify the gaps.
  • ✅ Branch coverage (--cov-branch) is more valuable than line coverage alone.
  • ✅ Configure source, omit and exclude_lines in pyproject.toml or .coveragerc.
  • ✅ The workflow: measure → identify gaps → write tests → measure again.

Next capsule: Interpreting coverage — what the numbers mean, when to worry about an uncovered line, and why 100% doesn't mean perfect code.


Additional Resources

  1. pytest-cov Documentation - Official pytest-cov documentation
  2. Coverage.py - The underlying measurement tool
  3. Coverage.py: Configuring - Configuration options
  4. Martin Fowler: Test Coverage - A perspective on coverage and its limits
  5. Python Testing: pytest-cov - Integration with pytest
  6. Ned Batchelder: Coverage.py Blog - Posts about coverage.py by its author

Module 5, Capsule 02 — Testing with Claude Code Guide