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
ifbut 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 writtenassert add(2, 2) == 5by 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
printadd 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 atests/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
| Column | Meaning |
|---|---|
| Name | The file or module measured |
| Stmts | The number of executable statements (lines of code that can run) |
| Miss | Statements that no test ran |
| Cover | The percentage: (Stmts - Miss) / Stmts |
| Missing | The 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:
- You open
src/auth.py. - Lines 23-28: you see an
if not user:that raisesUnauthorizedError. - Lines 35-40: you see an
except ValidationErrorthat returns a specific message. - 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
| Situation | Recommendation |
|---|---|
| CI/CD, a quick commit | term-missing — fast, in the same output as pytest |
| Deep analysis, new code | HTML — visual exploration |
| A code review of coverage | HTML — share htmlcov/ with the team |
| Debugging a specific file | Both — 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:
-
Measure after each feature: Run
pytest --cov=src --cov-report=term-missing tests/after implementing. Share the output (or theMissingcolumn) with Claude Code with the prompt: "These lines aren't covered. Generate tests that cover them." -
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."
-
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. -
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
- You look at
Missing: 14-21. - You open
src/calc.pyand see that they'remultiplyanddivide. - You write tests for those functions (including the
divide(x, 0)case to cover theif b == 0branch). - You run coverage again. The percentage goes up and
Missingshrinks 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 verifiesZeroDivisionError.
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
| Goal | Command |
|---|---|
| Basic coverage | pytest --cov=src tests/ |
| See the missing lines | pytest --cov=src --cov-report=term-missing tests/ |
| An HTML report | pytest --cov=src --cov-report=html tests/ |
| Include branch coverage | pytest --cov=src --cov-branch tests/ |
| All together | pytest --cov=src --cov-report=term-missing --cov-report=html --cov-branch tests/ |
| Just one file | pytest 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:
- Run
pytest --cov=src --cov-report=term-missing tests/ - Copy the output and paste it into the chat with Claude Code
- Ask for tests for the lines in
Missing - 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 < loworvalue > highbranches.
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:
- You clone or open the existing code.
- You run
pytest --cov=src --cov-report=term-missing tests/. - You see the report: for example 35% coverage, with
Missingindicating lines 45-60, 120-135, etc. - You identify the most critical gaps (business logic, error handling).
- You use Claude Code to generate tests that cover those gaps (the following capsules).
- 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=htmlgenerateshtmlcov/index.htmlfor 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,omitandexclude_linesin 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
- pytest-cov Documentation - Official pytest-cov documentation
- Coverage.py - The underlying measurement tool
- Coverage.py: Configuring - Configuration options
- Martin Fowler: Test Coverage - A perspective on coverage and its limits
- Python Testing: pytest-cov - Integration with pytest
- Ned Batchelder: Coverage.py Blog - Posts about coverage.py by its author
Module 5, Capsule 02 — Testing with Claude Code Guide