Module 5: Coverage and Edge Cases

Interpreting Coverage: What the Numbers Mean

Interpreting Coverage: What the Numbers Mean

Capsule overview

You run pytest --cov and see 78% line coverage. Is that good or bad? Should you chase 100%? And if a module has 95% coverage but fails in production because of a bug in an except that was never tested, what does that 95% really tell you?

This capsule answers the most important question about coverage: the numbers aren't the goal — they're a guide. Coverage tells you where to look for missing tests. It doesn't tell you which tests matter. 100% coverage with trivial asserts is useless. 60% coverage with tests that verify critical behavior is worth more than 100% with padding.

By the end, you'll know how to interpret a coverage report line by line, tell critical gaps from acceptable ones, prioritize what to cover first, and use Claude Code to close the gaps that really matter.


Coverage Numbers Are Not Quality Numbers

100% coverage with trivial asserts = useless

Imagine this suite:

# my_module.py
def calculate_invoice(items: list[dict], tax_rate: float) -> float:
    if not items:
        raise ValueError("Items cannot be empty")
    subtotal = sum(item["price"] * item["qty"] for item in items)
    return round(subtotal * (1 + tax_rate), 2)


# test_my_module.py (the trivial version)
def test_calculate_invoice_not_empty():
    result = calculate_invoice([{"price": 10, "qty": 2}], 0.1)
    assert result is not None

def test_calculate_invoice_returns_float():
    result = calculate_invoice([{"price": 100, "qty": 1}], 0.1)
    assert isinstance(result, float)

Coverage: 100%. Every line ran. But if someone changes the formula to return 0, the tests still pass. The coverage lies: it tells you everything is covered, but you didn't verify the correct value.

60% coverage with meaningful tests > 100% trivial

Compare with this suite:

# test_my_module.py (the meaningful version)
def test_calculate_invoice_correct_result():
    result = calculate_invoice([{"price": 100, "qty": 2}], 0.1)
    assert result == 220.0  # 200 * 1.1

def test_calculate_invoice_empty_raises():
    with pytest.raises(ValueError, match="Items cannot be empty"):
        calculate_invoice([], 0.1)

Coverage: maybe 60%. But if someone breaks the formula or the validation, the tests fail. Those tests protect against real bugs.

Golden rule: Coverage tells you where the tests go. It doesn't tell you whether those tests verify the right thing.

The correct mental model

When you read a coverage report, ask yourself:

  • "Does this percentage reflect tests that would catch bugs?" — Not automatically.
  • "Where are the most dangerous holes?" — That's where the report helps.
  • "Is it worth writing a test for this line?" — It depends on the priority.

Coverage is a discovery tool, not a final quality metric. A team that understands this uses the report to prioritize work. A team that doesn't wastes time chasing vain numbers.


Line Coverage vs Branch Coverage

The problem: one line can have multiple paths

Consider this function:

# calculator.py
def process(value: int) -> int:
    if value > 0:        # Branch 1a: True, Branch 1b: False
        return value * 2
    elif value == 0:     # Branch 2a: True, Branch 2b: False
        return 0
    else:
        return -value

If you write a single test:

def test_process_positive():
    assert process(5) == 10

That test runs:

  • Line 2: if value > 0 → True
  • Line 3: return value * 2
  • It doesn't run: line 4 (elif), 5, 6, 7

Line coverage: 3 out of 7 lines ≈ 43%

Branch coverage: Each if/elif/else creates branches. There are 6 logical branches:

  1. value > 0 → True ✅
  2. value > 0 → False
  3. value == 0 → True
  4. value == 0 → False
  5. else (value < 0) → executed
  6. else → not executed (already covered if you reach the else)

In practice, branch coverage counts: how many decision branches were taken? With a single process(5) test, you only took the "True" branch of the first if. The other branches were left uncovered.

Branch coverage: ≈ 17% (1 out of 6 branches)

An if/else with only the True covered = 50% branch coverage

def check_even(n: int) -> str:
    if n % 2 == 0:
        return "even"
    else:
        return "odd"

A test with check_even(4) covers 100% of the lines. But branch coverage: only the if's True branch ran. The False branch (else) didn't. If the implementation had a bug in the else, you wouldn't detect it.

Branch coverage reveals logic paths you didn't think to test.

How to see branch coverage with pytest-cov

pytest --cov=my_module --cov-report=term-missing --cov-branch -v

The --cov-branch flag enables branch coverage measurement. The report will show something like:

Name           Stmts   Miss Branch BrPart  Cover
-----------------------------------------------
calculator.py      7      4      6      5    17%

BrPart = partially covered branches. Branch = the total number of branches.

Function coverage: what it is and when it matters

Besides line and branch, there's function coverage: what percentage of functions were called at least once. It's the weakest metric: if a function has 100 lines and you only called the function once, you have 100% function coverage but maybe 10% line coverage. It's usually useful as a quick view ("are there functions nobody tests?"), but it doesn't replace line/branch for making decisions.


What Uncovered Lines Mean

Not all uncovered lines are equal. There are four typical categories:

1. Error handling code (except blocks)

def fetch_user(user_id: str) -> dict:
    try:
        return api.get(f"/users/{user_id}")
    except ConnectionError:
        logger.warning("API unavailable, using cache")
        return cache.get(user_id, {})
    except ValueError:
        raise

except blocks rarely run in normal tests. If you only test the happy path, coverage reports those lines as "missed". But they're critical: in production, network or validation errors do happen. If the fallback is badly implemented, you'll have silent bugs.

2. Fallback or default code

def get_theme(user) -> str:
    if user.preferences:
        return user.preferences.theme
    return "default"  # ← An uncovered line if you only test with user.preferences

The default value seems obvious, but if someone changes "default" to "light" and there's code that depends on the exact string, a bug appears. Defaults and fallbacks deserve at least one test.

3. Dead code

def calculate(x: int) -> int:
    if x < 0:
        return 0
    if False:  # ← Never runs
        return -1
    return x * 2

Lines that never run because the condition is impossible or there's redundant logic. You shouldn't write tests to cover them — you should delete that code.

4. Complex conditions

if (a and b) or (c and not d):
    do_something()

With multiple combinations of a, b, c, d, it's easy for some branches to be left uncovered. Branch coverage helps you identify which combination is missing.


Prioritizing What to Cover

Not all uncovered lines deserve the same attention. Use this framework: "What would hurt most if this code had a bug?"

Priority 1: Error handling (except, raise, error returns)

  • except blocks that fall back or return a default value
  • raise of custom exceptions
  • Error responses in APIs (400, 404, 500)

Risk: Bugs in errors = silent failures or incorrect messages in production.

Priority 2: Business logic branches (if/else in core functions)

  • Conditional calculations (discounts, taxes, limits)
  • Validations (ranges, types, formats)
  • Alternative flows (payment vs credit, subscriber vs guest)

Risk: Incorrect logic in one branch = wrong data or unexpected behavior.

Priority 3: Edge case paths (empty inputs, None, limits)

  • if not items, if value is None, if len(s) == 0
  • Boundary values: 0, -1, MAX_INT, an empty string

Risk: Crashes or incorrect results with unusual inputs.

Priority 4: Logging, formatting, cosmetic utilities

  • logger.debug(...), logger.info(...)
  • String formatting for messages
  • Helpers that only forward to other functions

Risk: Low. A bug here rarely affects the correct result. Cover it if you have spare time, but don't prioritize it.

A quick checklist for prioritizing gaps

When you see "Missing" lines in the report, ask in order:

  1. Is it an except or raise block? → Priority 1
  2. Is it an if/else in core logic? → Priority 2
  3. Is it input validation (empty, None, limits)? → Priority 3
  4. Is it logging, formatting or a trivial getter? → Priority 4
  5. Does it look impossible to run (dead code)? → Delete it, don't test it

This checklist helps you decide in seconds whether a gap deserves a test or can stay.


The 100% Coverage Trap

Chasing 100% leads to implementation tests

When the goal is "reach 100%", you end up writing tests like:

def test_user_name_getter():
    u = User(name="Alice")
    assert u.name == "Alice"

def test_user_str():
    u = User(name="Alice")
    assert "Alice" in str(u)

Those tests verify implementation (getters, __str__), not behavior. If you change the internal implementation (for example, name becomes a computed property), the test breaks even though the behavior is the same. Fragile tests, little value.

Getters, setters, str, logging → a waste of time

  • Tests for a @property that only returns an attribute
  • Tests for a __str__ that formats strings
  • Tests for functions that only call logger.debug

Better to invest that time in integration tests or edge cases of real logic.

Better: 90% with meaningful tests than 100% with padding

A module with 90% coverage where:

  • All the important error paths are covered
  • The business logic has parametrized tests
  • The critical edge cases are verified

…is worth more than a module with 100% coverage achieved by adding tests for __repr__ and logging.


A Real Example: Analyzing a Coverage Report

The module under analysis

# pricing.py
import logging
logger = logging.getLogger(__name__)

def apply_discount(price: float, discount_pct: float, min_price: float = 0) -> float:
    """Apply discount. Returns discounted price or min_price if lower."""
    if price < 0:
        raise ValueError("Price cannot be negative")
    if not 0 <= discount_pct <= 100:
        raise ValueError("Discount must be between 0 and 100")
    discounted = price * (1 - discount_pct / 100)
    if discounted < min_price:
        logger.info(f"Capping at min_price={min_price}")
        return min_price
    return round(discounted, 2)


def validate_coupon(code: str) -> bool:
    """Validate coupon format. Real validation would call external API."""
    if not code or not code.strip():
        return False
    if len(code) < 5:
        return False
    # Dead code: this condition is never met in current practice
    if code == "REMOVED_COUPON":
        return False
    return True

The current tests

# test_pricing.py
import pytest
from pricing import apply_discount, validate_coupon

def test_apply_discount_basic():
    assert apply_discount(100, 10) == 90.0

def test_apply_discount_zero_discount():
    assert apply_discount(50, 0) == 50.0

def test_validate_coupon_valid():
    assert validate_coupon("SAVE20") is True

The coverage report (simplified)

Name        Stmts   Miss Branch BrPart  Cover   Missing
-------------------------------------------------------
pricing.py     18      6     10      7    67%   5-8, 11-14, 17, 23-24

The uncovered lines:

  • 5-8: Validations (price < 0, discount_pct out of range) — Priority 1
  • 11-14: The if discounted < min_price block — Priority 2
  • 17: Implicit: the return round(...) when it doesn't enter the if — covered by the basic test, but the min_price default is never used in the covered path
  • 23-24: validate_coupon with an empty or very short code — Priority 3
  • The dead code line (code == "REMOVED_COUPON") — Don't prioritize, delete the dead code

The decision: what to close and what to accept

  • ✅ Close: Tests for apply_discount with price < 0 and an invalid discount_pct (Priority 1)
  • ✅ Close: A test for apply_discount with min_price when discounted < min_price (Priority 2)
  • ✅ Close: Tests for validate_coupon with "", " ", "abc" (Priority 3)
  • ❌ Don't close with a test: The code == "REMOVED_COUPON" line — delete it or document it as legacy
  • ❌ Accept without a test: The logger.info — Priority 4, low risk

Before and after: the report once the priority gaps are closed

Before (67%):

pricing.py     18      6     10      7    67%   5-8, 11-14, 17, 23-24

After adding the recommended tests (Priority 1, 2, 3):

pricing.py     18      2      10      3    89%   19-20, 27

What's left uncovered: the logger.info (Priority 4) and the REMOVED_COUPON dead code. You've gone from 67% to 89% by covering only what matters. The rest you can accept, or remove the dead code in a refactor.


Using Claude Code to Close Coverage Gaps

The effective prompt

You can give Claude Code the report and ask for specific tests:

Here's my coverage report. Lines 5-8 and 11-14 of pricing.py aren't covered.

pricing.py code:
[paste the code]

Current tests:
[paste test_pricing.py]

Generate tests that cover those lines. The tests must verify behavior, not just run the code.
Use pytest and pytest.raises for the exceptions.

A typical Claude Code response

def test_apply_discount_negative_price_raises():
    with pytest.raises(ValueError, match="Price cannot be negative"):
        apply_discount(-10, 5)

def test_apply_discount_invalid_discount_raises():
    with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
        apply_discount(100, 150)

def test_apply_discount_caps_at_min_price():
    result = apply_discount(100, 90, min_price=15)
    assert result == 15  # 100 * 0.1 = 10, but min_price is 15

Verifying the new coverage

pytest test_pricing.py --cov=pricing --cov-report=term-missing

You should see lines 5-8 and 11-14 go from "Missing" to covered.

Prompt variations for different scenarios

  • For error handling: "Lines X-Y are an except block. Generate a test that forces that exception (a mock, an invalid input, etc.) and verifies the fallback."

  • For branch coverage: "The False branch of the if on line N isn't covered. Generate a test with an input that takes that branch and verifies the return value."

  • For edge cases: "The function validates inputs. Generate parametrized tests for: empty, None, zero, negative, a very long string."

Each type of gap has a more effective prompt. The more specific you are about what to cover and what to verify, the better Claude Code's output will be.


A Practical Checklist for Interpreting Reports

When you open a coverage report, follow these steps in order:

  1. Run it with branch coverage:

    pytest --cov=your_module --cov-branch --cov-report=term-missing -v

    Without --cov-branch you lose critical information about uncovered branches.

  2. List the "Missing" lines per file — don't try to cover them all. Classify them into:

    • Priority 1 (error handling)
    • Priority 2 (business logic)
    • Priority 3 (edge cases)
    • Priority 4 or "delete" (logging, dead code)
  3. For each Priority 1 or 2 line: Ask yourself "What would happen if this line had a bug?" If the answer is "a silent failure", "incorrect data" or "a crash in production", write or generate the test.

  4. Review the branch coverage: If BrPart (partial branches) is high, you have many if/else statements where you only covered one branch. Identify the missing branch and an input that runs it.

  5. Don't chase 100% unless your team has an explicit policy. A target of 85-90% with intelligent prioritization is usually healthier than 100% with filler tests.

  6. Use Claude Code for concrete gaps: Instead of "improve the coverage", say "lines 14-16 of validators.py aren't covered. It's an except block. Generate a test that forces that exception and verifies the fallback's return value."

This checklist transforms you from "I don't know what to do with this 67%" to "I have a clear action plan".

When NOT to write tests to close coverage

There are lines that don't deserve a test. Identifying them saves you time and keeps the suite clean:

  • Verifiable dead code: If a branch is impossible (e.g. if False), delete it. No tests.
  • Pure logging: logger.debug(...), logger.info(...) with no logic — Priority 4, skip it unless you have a strict policy.
  • Delegated methods: If def get_x(self): return self._x only forwards, the caller's test already covers the behavior. Additional tests are redundant.
  • Generated code or boilerplate: Serializers, DTOs that only map fields — the value of testing them is low.
  • Team policy: If the team agreed on 85% as a target and you've already reached it in the critical modules, don't chase more just for the number.

The key: every test must have a reason. "To raise the coverage" isn't a sufficient reason if the test wouldn't detect a relevant bug.

Integration with the TDD flow

When you work with TDD + Claude Code, coverage doesn't replace the red-green-refactor cycle. It complements it:

  • During development: You write specs (tests) first. The feature's initial coverage will be high because the tests defined the behavior.
  • After refactors: Coverage reveals whether you deleted tests or whether there are new branches that emerged from the refactor.
  • In legacy code: Coverage is your starting point: you measure first, identify gaps, generate tests with Claude Code.

Don't use coverage as an excuse to write tests afterwards. In new code, TDD remains the preferred flow. Coverage comes in when you have existing code or when you want to validate that you didn't leave gaps in a big refactor.


Exercises

Exercise 1: Calculate line vs branch coverage (Easy)

Given this function, what approximate percentage of line coverage and branch coverage do you get if you only have a test with categorize(5)?

def categorize(value: int) -> str:
    if value > 10:
        return "high"
    elif value > 0:
        return "low"
    else:
        return "zero"
See solution
  • Line coverage: 4 out of 7 lines ≈ 57% (what runs: if, elif True, return "low")
  • Branch coverage: 2 out of 6 branches ≈ 33% (only value > 10 False, value > 0 True)

For 100% branch coverage you need at least: categorize(15), categorize(5), categorize(-3).


Exercise 2: Identify the priority of gaps (Easy)

Classify each uncovered line as Priority 1, 2, 3 or 4:

def process_order(order: dict) -> dict:
    if not order.get("items"):
        raise ValueError("Order must have items")
    total = sum(i["price"] * i["qty"] for i in order["items"])
    try:
        tax = external_api.get_tax(total)
    except TimeoutError:
        logger.warning("Tax API timeout, using 0")
        tax = 0
    return {"total": total, "tax": tax}

Uncovered lines: 2 (raise), 7-8 (the except block).

See solution
  • Line 2 (raise ValueError): Priority 2 — business logic/validation
  • Lines 7-8 (except TimeoutError): Priority 1 — critical error handling (a fallback on API failure)

The recommended order to close them: first the except (Priority 1), then the raise (Priority 2).


Exercise 3: Write tests to increase branch coverage (Medium)

This function has 50% branch coverage with a single test. Write the missing tests to reach 100% branch coverage.

def parse_level(level: str) -> int:
    if level == "debug":
        return 10
    elif level == "info":
        return 20
    elif level == "warning":
        return 30
    else:
        return 0
See solution
import pytest
from my_module import parse_level

@pytest.mark.parametrize("level,expected", [
    ("debug", 10),
    ("info", 20),
    ("warning", 30),
    ("error", 0),
    ("", 0),
    ("unknown", 0),
])
def test_parse_level_all_branches(level, expected):
    assert parse_level(level) == expected

Or individual tests for each branch. What matters: covering "debug", "info", "warning" and at least one case of the else.


Exercise 4: Analyze a report and prioritize (Medium)

Given this report:

pricing.py     Stmts  Miss  Cover   Missing
                 22     5    77%    9, 12-13, 19-20

And the code (an excerpt):

# Line 9: raise ValueError("Invalid")
# Lines 12-13: except ValueError: return default
# Lines 19-20: logger.debug("..."); helper()

Which gaps do you close first and which do you accept without covering?

See solution
  • Line 9 (raise): Priority 2 — close it. A test with pytest.raises(ValueError).
  • Lines 12-13 (except): Priority 1 — close it. A test that forces the ValueError and verifies the default.
  • Lines 19-20 (logging + helper): Priority 4 — accept without covering. Or close it only if helper() has important logic; if it's pure logging, skip it.

The order: 12-13 → 9 → (19-20 optional).


Exercise 5: A prompt for Claude Code (Medium)

Write a prompt you'd give Claude Code to generate tests that cover lines 14-16 of this module:

# validators.py
def validate_email(email: str) -> bool:
    if not email or "@" not in email:
        return False
    local, domain = email.split("@", 1)
    if len(local) < 2 or "." not in domain:  # Lines 6-7
        return False
    return True
See solution
Lines 6-7 of validators.py aren't covered. That branch runs when local has fewer than 2 characters OR domain doesn't contain a ".".

Code:
[paste validators.py]

Generate parametrized pytest tests that cover those lines. The cases must include:
- an email with a 1-character local part
- an email with a domain that has no period

Each test must assert on the return value (True/False).

Exercise 6: Detect dead code (Hard)

In this snippet there's a line that never runs. Identify it and explain why.

def get_status(code: int) -> str:
    if code >= 200 and code < 300:
        return "ok"
    if code >= 400:
        return "error"
    if code >= 300 and code < 400:
        return "redirect"
    return "unknown"  # When does this run?
See solution

The return "unknown" line is dead code given the current flow. The reason:

  • code >= 200 and code < 300 → return "ok"
  • code >= 400 → return "error"
  • code >= 300 and code < 400 → return "redirect"

Which integers are left? Those < 200 (100-199, 0-99). Those would fall into "unknown". But if the function only receives typical HTTP codes (200-599), in practice you'd never get there. It depends on the contract: if the function accepts any int, "unknown" does run for codes < 200. If it only accepts 200-599, then "unknown" is dead code for the real domain.

To confirm: run it with get_status(199) or get_status(0). If the contract says "valid HTTP codes only", consider removing the branch or turning it into raise ValueError("Invalid HTTP code").


Troubleshooting

Problem 1: The report shows 0% branch coverage

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

Solution:

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

In pyproject.toml or setup.cfg:

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

Problem 2: Coverage includes files you don't want (venv, tests)

Cause: By default, coverage measures everything that gets imported.

Solution: Configure omit in .coveragerc or pyproject.toml:

[run]
omit =
    tests/*
    venv/*
    */__pycache__/*

Problem 3: Lines marked as "missing" but I did run them in the test

Cause: Sometimes coverage doesn't detect execution because of line jumps or optimizations.

Solution: Verify that the test really calls the expected path. Add a temporary print or a breakpoint() to confirm. If the test is in another process (e.g. multiprocessing), coverage might not count it — run the tests in the same process.


Problem 4: Very low branch coverage even though line coverage is high

Cause: Many if/else statements where you only tested the True branch (or the most common one).

Solution: Review the report with --cov-report=term-missing and look for "partial" branches. For each if, ensure at least one test with a True condition and another with False. Use @pytest.mark.parametrize to cover several combinations.


Problem 5: Tests generated by Claude Code cover lines but don't verify behavior

Cause: Vague prompts like "generate tests to cover this code".

Solution: Be explicit in the prompt:

Generate tests that:
1. Cover lines X-Y
2. Verify the exact expected value (not assert result is not None)
3. Use pytest.raises for exceptions
4. Include at least one edge case per branch

Project Connection

The module's project (capsule 06) consists of bringing an existing module up to ≥90% coverage. This capsule gives you the judgment to:

  • Interpret the coverage report and decide which gaps to close first
  • Not chase 100% at the cost of trivial tests
  • Prioritize error handling and business logic over logging and formatting
  • Use Claude Code with specific prompts to generate tests that cover concrete lines and verify behavior

The recommended flow: measure → identify the critical gaps (Priority 1 and 2) → generate tests with Claude Code → measure again → iterate.

Next capsule: Capsule 04 covers identifying and designing edge cases with Claude Code.


Integrating it into your daily workflow

Once you internalize the prioritization (error handling → logic → edge cases → logging), the coverage report stops being a number to chase and becomes a prioritized to-do list. Each coding session can include: "today I close the Priority 1 gaps in auth.py". Claude Code speeds up the generation; you decide which gaps matter.

The winning combination: you interpret, Claude Code implements. You identify that lines 23-25 are a critical except block. You give Claude Code the context and the specific prompt. You receive tests that cover those lines and verify the fallback. You run pytest --cov, confirm the gap closed, and move on to the next one. That loop — interpret, prioritize, generate, verify — is the core of professional coverage with AI.


Summary

  • ✅ Coverage indicates where to look for tests, not whether the tests are good
  • ✅ 100% coverage with trivial asserts is barely useful; 60% with meaningful tests is worth more
  • ✅ Branch coverage reveals uncovered logic branches (if/else)
  • ✅ Uncovered lines: error handling (critical), business logic, edge cases, logging (low priority), dead code (delete)
  • ✅ Prioritize: error handling → business logic → edge cases → logging
  • ✅ Chasing 100% leads to implementation tests; better 90% with tests that add value
  • ✅ Claude Code can generate tests for concrete gaps with prompts that ask for behavior verification, not just execution

Additional Resources

  1. Coverage.py - Branch Coverage — Official branch coverage documentation
  2. Martin Fowler: Test Coverage — Why coverage doesn't measure quality
  3. pytest-cov Usage — Configuration and options
  4. The Pragmatic Programmer: Good Enough Software — A perspective on "good enough" in metrics
  5. Test Coverage Best Practices — When and how to use coverage
  6. Mutation Testing with mutmut — A tool to detect tests that don't verify behavior

Module 5, Capsule 03 — Testing with Claude Code Guide