Module 5: Coverage and Edge Cases

Module Project: A Suite with 90%+ Coverage

Module Project: A Suite with 90%+ Coverage

Project overview

You receive a Python module with complete functionality but no tests (or with minimal tests). Your job is to use everything you've learned — pytest-cov, report interpretation, edge case discovery with Claude Code, boundary testing, and property-based testing with hypothesis — to take the coverage from 0% to ≥90% following an iterative process.

It isn't about writing tests mechanically until you reach the number. It's about using the workflow: measure → identify critical gaps → generate tests with Claude Code → measure again → discover edge cases → measure again. Each iteration closes specific gaps with meaningful tests.


Project Objective

Take an existing module from 0% coverage to ≥90% using Claude Code as a test generator and your judgment to prioritize what to cover.

By completing this project:

  • ✅ You'll have run the iterative coverage cycle at least 3 times
  • ✅ You'll have used Claude Code to generate tests that close specific gaps
  • ✅ You'll have discovered edge cases with systematic prompts
  • ✅ You'll have implemented at least 2 property-based tests with hypothesis
  • ✅ You'll have a suite with ≥90% line coverage and ≥80% branch coverage

Technical Specifications

Technology Stack

  • Language: Python 3.10+
  • Testing: pytest, pytest-cov, hypothesis
  • AI: Claude Code

Initial Setup

mkdir coverage-project
cd coverage-project
python -m venv venv
source venv/bin/activate
pip install pytest pytest-cov hypothesis

Project Structure

coverage-project/
├── data_processor/
│   ├── __init__.py
│   ├── cleaner.py        ← Data cleaning (given)
│   ├── transformer.py    ← Transformations (given)
│   ├── validator.py      ← Validations (given)
│   └── aggregator.py     ← Aggregations (given)
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_cleaner.py       ← You create it
│   ├── test_transformer.py   ← You create it
│   ├── test_validator.py     ← You create it
│   └── test_aggregator.py    ← You create it
├── pyproject.toml
└── requirements.txt

The Code to Test

cleaner.py

"""Data cleaning utilities."""

import re
from typing import Optional


def clean_string(value: str) -> str:
    if not isinstance(value, str):
        raise TypeError(f"Expected string, got {type(value).__name__}")
    cleaned = value.strip()
    cleaned = re.sub(r'\s+', ' ', cleaned)
    return cleaned


def clean_email(email: str) -> str:
    cleaned = clean_string(email).lower()
    if '@' not in cleaned:
        raise ValueError(f"Invalid email format: {email}")
    local, domain = cleaned.rsplit('@', 1)
    if not local or not domain:
        raise ValueError(f"Invalid email format: {email}")
    if '.' not in domain:
        raise ValueError(f"Invalid email domain: {domain}")
    return f"{local}@{domain}"


def clean_phone(phone: str) -> str:
    digits = re.sub(r'\D', '', phone)
    if len(digits) < 7 or len(digits) > 15:
        raise ValueError(f"Invalid phone number: {phone}")
    if len(digits) == 10:
        return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
    elif len(digits) == 11 and digits[0] == '1':
        return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:]}"
    return digits


def remove_duplicates(items: list, key: Optional[str] = None) -> list:
    if not items:
        return []
    if key:
        seen = set()
        result = []
        for item in items:
            val = item.get(key) if isinstance(item, dict) else getattr(item, key, None)
            if val is None:
                raise ValueError(f"Key '{key}' not found in item: {item}")
            if val not in seen:
                seen.add(val)
                result.append(item)
        return result
    return list(dict.fromkeys(items))

transformer.py

"""Data transformation utilities."""

from typing import Any
from datetime import datetime


def to_snake_case(name: str) -> str:
    if not name:
        return ""
    result = name[0].lower()
    for char in name[1:]:
        if char.isupper():
            result += '_' + char.lower()
        elif char == ' ' or char == '-':
            result += '_'
        else:
            result += char
    return result


def flatten_dict(data: dict, prefix: str = "", separator: str = ".") -> dict:
    items = {}
    for key, value in data.items():
        new_key = f"{prefix}{separator}{key}" if prefix else key
        if isinstance(value, dict):
            items.update(flatten_dict(value, new_key, separator))
        elif isinstance(value, list):
            for i, item in enumerate(value):
                if isinstance(item, dict):
                    items.update(flatten_dict(item, f"{new_key}[{i}]", separator))
                else:
                    items[f"{new_key}[{i}]"] = item
        else:
            items[new_key] = value
    return items


def convert_types(value: Any, target_type: str) -> Any:
    converters = {
        "int": int,
        "float": float,
        "str": str,
        "bool": lambda v: v.lower() in ('true', '1', 'yes') if isinstance(v, str) else bool(v),
        "datetime": lambda v: datetime.fromisoformat(v) if isinstance(v, str) else v,
    }
    if target_type not in converters:
        raise ValueError(f"Unsupported type: {target_type}")
    try:
        return converters[target_type](value)
    except (ValueError, TypeError, AttributeError) as e:
        raise ValueError(f"Cannot convert {value!r} to {target_type}: {e}")


def batch_transform(items: list[dict], transformations: dict[str, str]) -> list[dict]:
    results = []
    for item in items:
        transformed = {}
        for field, target_type in transformations.items():
            if field in item:
                transformed[field] = convert_types(item[field], target_type)
            else:
                transformed[field] = None
        for field in item:
            if field not in transformations:
                transformed[field] = item[field]
        results.append(transformed)
    return results

validator.py

"""Data validation utilities."""

import re
from typing import Any, Optional


class ValidationResult:
    def __init__(self):
        self.errors: list[str] = []
        self.warnings: list[str] = []
    
    @property
    def is_valid(self) -> bool:
        return len(self.errors) == 0
    
    def add_error(self, message: str):
        self.errors.append(message)
    
    def add_warning(self, message: str):
        self.warnings.append(message)
    
    def __repr__(self):
        return f"ValidationResult(valid={self.is_valid}, errors={len(self.errors)}, warnings={len(self.warnings)})"


def validate_schema(data: dict, schema: dict[str, dict]) -> ValidationResult:
    result = ValidationResult()
    for field, rules in schema.items():
        value = data.get(field)
        
        if rules.get("required") and value is None:
            result.add_error(f"Field '{field}' is required")
            continue
        
        if value is None:
            continue
        
        expected_type = rules.get("type")
        if expected_type and not isinstance(value, expected_type):
            result.add_error(f"Field '{field}' must be {expected_type.__name__}, got {type(value).__name__}")
        
        min_val = rules.get("min")
        if min_val is not None and isinstance(value, (int, float)) and value < min_val:
            result.add_error(f"Field '{field}' must be >= {min_val}")
        
        max_val = rules.get("max")
        if max_val is not None and isinstance(value, (int, float)) and value > max_val:
            result.add_error(f"Field '{field}' must be <= {max_val}")
        
        pattern = rules.get("pattern")
        if pattern and isinstance(value, str) and not re.match(pattern, value):
            result.add_error(f"Field '{field}' does not match pattern '{pattern}'")
        
        max_length = rules.get("max_length")
        if max_length and isinstance(value, str) and len(value) > max_length:
            result.add_warning(f"Field '{field}' exceeds max length {max_length}")
    
    for field in data:
        if field not in schema:
            result.add_warning(f"Unexpected field: '{field}'")
    
    return result


def validate_batch(items: list[dict], schema: dict[str, dict]) -> list[ValidationResult]:
    return [validate_schema(item, schema) for item in items]

aggregator.py

"""Data aggregation utilities."""

from typing import Any, Callable, Optional
from collections import defaultdict


def group_by(items: list[dict], key: str) -> dict[Any, list[dict]]:
    if not items:
        return {}
    groups = defaultdict(list)
    for item in items:
        if key not in item:
            raise KeyError(f"Key '{key}' not found in item: {item}")
        groups[item[key]].append(item)
    return dict(groups)


def aggregate(
    items: list[dict],
    group_key: str,
    value_key: str,
    func: str = "sum",
) -> dict[Any, Any]:
    groups = group_by(items, group_key)
    agg_funcs: dict[str, Callable] = {
        "sum": sum,
        "avg": lambda vals: sum(vals) / len(vals) if vals else 0,
        "min": min,
        "max": max,
        "count": len,
    }
    if func not in agg_funcs:
        raise ValueError(f"Unknown aggregation: {func}. Use: {', '.join(agg_funcs)}")
    
    result = {}
    for group, group_items in groups.items():
        values = [item[value_key] for item in group_items if value_key in item]
        if not values and func != "count":
            result[group] = None
        else:
            result[group] = agg_funcs[func](values if func != "count" else group_items)
    return result


def top_n(items: list[dict], key: str, n: int = 5, reverse: bool = True) -> list[dict]:
    if not items:
        return []
    if n <= 0:
        raise ValueError("n must be positive")
    return sorted(items, key=lambda x: x.get(key, 0), reverse=reverse)[:n]


def compute_stats(values: list[float]) -> dict[str, float]:
    if not values:
        return {"count": 0, "sum": 0, "avg": 0, "min": 0, "max": 0, "range": 0}
    n = len(values)
    total = sum(values)
    avg = total / n
    min_val = min(values)
    max_val = max(values)
    sorted_vals = sorted(values)
    median = (
        sorted_vals[n // 2]
        if n % 2 == 1
        else (sorted_vals[n // 2 - 1] + sorted_vals[n // 2]) / 2
    )
    return {
        "count": n,
        "sum": total,
        "avg": avg,
        "min": min_val,
        "max": max_val,
        "range": max_val - min_val,
        "median": median,
    }

Step-by-Step Process

Iteration 1: Measure and generate the base tests

# Measure the current coverage (it should be 0%)
pytest --cov=data_processor --cov-report=term-missing tests/

A prompt to Claude Code:

Generate unit tests for data_processor/cleaner.py.
Cover the happy path and error handling.
Use parametrize for the variants.
AAA pattern, descriptive naming.

Repeat for each file. Measure the coverage.

Iteration 2: Close gaps with edge cases

# Measure after the base tests
pytest --cov=data_processor --cov-report=term-missing --cov-branch tests/

Identify the Missing lines. The prompt:

My coverage for data_processor/transformer.py shows uncovered 
lines: [lines]. Generate tests that cover those lines.
Include edge cases: empty inputs, None, wrong types.

Iteration 3: Property testing and boundaries

# tests/conftest.py
import pytest


@pytest.fixture
def sample_items():
    return [
        {"name": "Alice", "age": 30, "dept": "eng"},
        {"name": "Bob", "age": 25, "dept": "sales"},
        {"name": "Carol", "age": 35, "dept": "eng"},
    ]

Add hypothesis to validate properties:

Generate property-based tests with hypothesis for
data_processor/aggregator.py. Define properties like:
- compute_stats always returns min <= avg <= max
- group_by preserves the total number of items
- top_n returns at most n items

Iteration 4: Branch coverage and refinement

pytest --cov=data_processor --cov-branch --cov-report=html tests/
# Open htmlcov/index.html and review the uncovered branches

Success Criteria

Your project is complete when:

  • ✅ pytest --cov=data_processor --cov-report=term-missing tests/ → ≥90% line coverage
  • ✅ pytest --cov=data_processor --cov-branch tests/ → ≥80% branch coverage
  • ✅ At least 2 property-based tests with hypothesis
  • ✅ Edge cases covered: empty inputs, None, boundary values, type errors
  • ✅ Tests organized by module (test_cleaner.py, test_transformer.py, etc.)
  • ✅ A minimum of 3 documented iterations of the measure→generate→measure cycle

Evaluation Rubric (100 points)

Coverage (30 points)

  • (15 pts) ≥90% line coverage
  • (10 pts) ≥80% branch coverage
  • (5 pts) No gaps in error handling (except blocks covered)

Test Quality (30 points)

  • (10 pts) The happy path covered for every function
  • (10 pts) Error handling tested (ValueError, TypeError, KeyError)
  • (10 pts) Edge cases covered (empty, None, boundary, types)

Property-Based Testing (15 points)

  • (10 pts) At least 2 property tests with hypothesis
  • (5 pts) Meaningful properties (not trivial ones)

Process (15 points)

  • (10 pts) At least 3 documented measure→generate→measure iterations
  • (5 pts) Use of Claude Code with specific prompts per iteration

Organization (10 points)

  • (5 pts) Tests organized by module
  • (5 pts) conftest.py with shared fixtures

Extra Credit (up to +10 points)

  • (+5 pts) ≥95% line coverage
  • (+5 pts) A property test that uncovers a real bug in the given code

Common Mistakes

Mistake 1: Writing tests to reach the number, not to validate behavior

Cause: Adding assert True or trivial tests just to raise the percentage.

Solution: Every test must verify a specific behavior. If a line isn't covered, ask yourself "what scenario runs it?" and write a test for that scenario.

Mistake 2: Ignoring branch coverage

Cause: Only looking at line coverage, which can be misleading.

Solution: Always run it with --cov-branch. Branch coverage reveals untested logical paths.

Mistake 3: Property tests without real properties

Cause: @given(st.integers()) def test_func(n): assert True — this doesn't test anything.

Solution: Define real properties: "count is always ≥0", "min ≤ avg ≤ max", "flatten followed by unflatten returns the original."

Mistake 4: Not documenting the iterations

Cause: Only delivering the final tests without the process.

Solution: Record it: "Iteration 1: 0%→62%. Iteration 2: 62%→81% (edge cases). Iteration 3: 81%→92% (hypothesis + branches)."


Resources for the Project

  1. pytest-cov Documentation - pytest-cov reference
  2. Hypothesis Documentation - Property-based testing
  3. Coverage.py Configuration - Advanced configuration
  4. Martin Fowler: Test Coverage - A perspective on coverage
  5. Property-Based Testing with Python - The hypothesis quickstart

Connection with the Next Module

What you built today expands in the following modules:

  • Module 6 (Mocking): Some coverage gaps require mocks for external services
  • Module 7 (Strategy): You'll define coverage targets as part of your testing strategy
  • Module 8 (Final Project): The target is ≥90% coverage for the whole application — the same workflow you practiced here

You've mastered the coverage cycle. Measure → identify → generate → measure. This iterative loop is the professional tool for maintaining quality in any project.


Module 5, Capsule 06 — Testing with Claude Code Guide From 0% to 90%+ coverage — the iterative workflow