Code Coverage Analysis and Quality Metrics
Code coverage is one of the most misunderstood metrics in software development. I’ve seen teams obsess over achieving 100% coverage while writing meaningless tests, and I’ve seen other teams ignore coverage entirely and miss critical untested code paths. The truth is that coverage is a useful tool when used correctly, but it’s not a goal in itself.
Coverage tells you what code your tests execute, not whether your tests are good. High coverage with poor tests gives you false confidence, while low coverage with excellent tests might indicate you’re testing the right things but missing edge cases.
Understanding Coverage Types
Different types of coverage measure different aspects of test completeness. Line coverage is the most common, but branch coverage often provides more valuable insights:
def calculate_grade(score, extra_credit=0):
"""Calculate letter grade with optional extra credit."""
total_score = score + extra_credit
if total_score >= 90: # Branch 1
return 'A'
elif total_score >= 80: # Branch 2
return 'B'
elif total_score >= 70: # Branch 3
return 'C'
elif total_score >= 60: # Branch 4
return 'D'
else: # Branch 5
return 'F'
# Test that achieves 100% line coverage but poor branch coverage
def test_calculate_grade_basic():
"""This test hits every line but not every branch."""
assert calculate_grade(95) == 'A' # Only tests one branch
# Better tests that cover all branches
def test_calculate_grade_all_branches():
"""Test all possible grade outcomes."""
assert calculate_grade(95) == 'A'
assert calculate_grade(85) == 'B'
assert calculate_grade(75) == 'C'
assert calculate_grade(65) == 'D'
assert calculate_grade(55) == 'F'
# Test edge cases
assert calculate_grade(89) == 'B' # Just below A threshold
assert calculate_grade(90) == 'A' # Exactly at A threshold
# Test extra credit
assert calculate_grade(85, 10) == 'A' # Extra credit pushes to A
Branch coverage ensures you test all possible code paths, not just all lines of code.
Setting Up Coverage Analysis
Use coverage.py to measure and analyze your test coverage effectively:
# Install coverage: pip install coverage
# Run tests with coverage
# coverage run -m pytest
# coverage report
# coverage html # Generate HTML report
# .coveragerc configuration file
[run]
source = src/
omit =
*/tests/*
*/venv/*
*/migrations/*
*/settings/*
setup.py
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
[html]
directory = htmlcov
This configuration focuses coverage analysis on your source code while excluding test files and other non-essential code.
Interpreting Coverage Reports
Coverage reports show you which lines aren’t tested, but interpreting this data requires understanding your code’s risk profile:
class UserService:
def __init__(self, database, email_service):
self.database = database
self.email_service = email_service
def create_user(self, username, email, password):
"""Create new user account."""
# High-risk code: validation and business logic
if not username or len(username) < 3:
raise ValueError("Username must be at least 3 characters")
if not self._is_valid_email(email):
raise ValueError("Invalid email address")
# Medium-risk code: database operations
existing_user = self.database.get_user_by_username(username)
if existing_user:
raise ValueError("Username already exists")
# High-risk code: password handling
hashed_password = self._hash_password(password)
user = User(username=username, email=email, password=hashed_password)
saved_user = self.database.save(user)
# Low-risk code: notification (nice to have, not critical)
try:
self.email_service.send_welcome_email(email) # pragma: no cover
except Exception:
# Email failure shouldn't break user creation
pass
return saved_user
def _is_valid_email(self, email):
"""Validate email format."""
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def _hash_password(self, password):
"""Hash password securely."""
import hashlib
return hashlib.sha256(password.encode()).hexdigest()
Focus your testing efforts on high-risk code paths. The email notification failure handling might not need test coverage if it’s truly non-critical.
Coverage-Driven Test Improvement
Use coverage reports to identify missing test scenarios, not just untested lines:
def process_payment(amount, payment_method, customer_tier):
"""Process payment with various business rules."""
if amount <= 0:
raise ValueError("Amount must be positive")
# Different processing based on payment method
if payment_method == "credit_card":
fee = amount * 0.03 # 3% fee
if customer_tier == "premium":
fee *= 0.5 # 50% discount for premium customers
elif payment_method == "bank_transfer":
fee = 5.00 # Flat fee
if amount > 1000:
fee = 0 # No fee for large transfers
else:
raise ValueError(f"Unsupported payment method: {payment_method}")
total_amount = amount + fee
# Risk assessment
if amount > 10000:
# High-value transaction requires additional verification
return {"status": "pending_verification", "amount": total_amount}
return {"status": "processed", "amount": total_amount}
# Coverage report shows these scenarios are untested:
def test_payment_processing_missing_scenarios():
"""Tests identified by coverage analysis."""
# Test premium customer credit card discount
result = process_payment(100, "credit_card", "premium")
assert result["amount"] == 101.50 # $100 + $1.50 fee (50% discount)
# Test large bank transfer (no fee)
result = process_payment(2000, "bank_transfer", "regular")
assert result["amount"] == 2000 # No fee for large transfers
# Test high-value transaction verification
result = process_payment(15000, "credit_card", "regular")
assert result["status"] == "pending_verification"
# Test edge case: exactly $10,000
result = process_payment(10000, "credit_card", "regular")
assert result["status"] == "processed" # Should not trigger verification
Coverage analysis revealed these untested scenarios that represent important business logic.
Mutation Testing for Test Quality
Coverage tells you if code is executed, but mutation testing tells you if your tests would catch bugs:
# Install mutmut: pip install mutmut
# Run: mutmut run
def calculate_discount(price, customer_type, order_count):
"""Calculate discount based on customer type and order history."""
if price < 0:
raise ValueError("Price cannot be negative")
base_discount = 0
if customer_type == "premium":
base_discount = 0.15 # 15% discount
elif customer_type == "regular":
base_discount = 0.05 # 5% discount
# Loyalty bonus
if order_count >= 10:
base_discount += 0.05 # Additional 5%
# Cap discount at 25%
final_discount = min(base_discount, 0.25)
return price * final_discount
# Strong test that would catch mutations
def test_calculate_discount_comprehensive():
"""Test that catches various potential bugs."""
# Test basic discounts
assert calculate_discount(100, "premium", 0) == 15.0
assert calculate_discount(100, "regular", 0) == 5.0
assert calculate_discount(100, "guest", 0) == 0.0
# Test loyalty bonus
assert calculate_discount(100, "regular", 10) == 10.0 # 5% + 5%
assert calculate_discount(100, "premium", 10) == 20.0 # 15% + 5%
# Test discount cap
assert calculate_discount(100, "premium", 15) == 25.0 # Capped at 25%
# Test edge cases
assert calculate_discount(100, "regular", 9) == 5.0 # Just below loyalty threshold
assert calculate_discount(0, "premium", 10) == 0.0 # Zero price
# Test error conditions
with pytest.raises(ValueError):
calculate_discount(-10, "regular", 5)
Mutation testing changes your code (mutates it) and checks if your tests fail. If tests still pass with mutated code, your tests might not be thorough enough.
Quality Metrics Beyond Coverage
Coverage is just one quality metric. Combine it with other measurements for a complete picture:
# Cyclomatic complexity analysis
def complex_function(data, options):
"""Function with high cyclomatic complexity (hard to test completely)."""
result = []
for item in data:
if options.get('filter_positive') and item > 0:
if options.get('double_values'):
if item % 2 == 0:
result.append(item * 2)
else:
result.append(item * 3)
else:
result.append(item)
elif options.get('filter_negative') and item < 0:
if options.get('absolute_values'):
result.append(abs(item))
else:
result.append(item)
elif item == 0 and options.get('include_zero'):
result.append(0)
return result
# Refactored for better testability
def process_items(data, options):
"""Refactored function with lower complexity."""
result = []
for item in data:
if should_include_item(item, options):
processed_item = transform_item(item, options)
result.append(processed_item)
return result
def should_include_item(item, options):
"""Separate function for inclusion logic."""
if item > 0 and options.get('filter_positive'):
return True
if item < 0 and options.get('filter_negative'):
return True
if item == 0 and options.get('include_zero'):
return True
return False
def transform_item(item, options):
"""Separate function for transformation logic."""
if item > 0 and options.get('double_values'):
return item * 2 if item % 2 == 0 else item * 3
elif item < 0 and options.get('absolute_values'):
return abs(item)
return item
Lower complexity functions are easier to test thoroughly and maintain.
Establishing Coverage Policies
Set realistic coverage targets based on your project’s risk profile and constraints:
# pytest.ini configuration
[tool:pytest]
addopts = --cov=src --cov-report=html --cov-report=term --cov-fail-under=80
# Different coverage requirements for different code types
# Critical business logic: 95%+ coverage
# API endpoints: 90%+ coverage
# Utility functions: 85%+ coverage
# Configuration/setup code: 70%+ coverage
Focus on meaningful coverage rather than arbitrary percentages. A well-tested critical function at 85% coverage is better than a trivial utility function at 100% coverage.
Coverage in CI/CD Pipelines
Integrate coverage analysis into your development workflow to catch coverage regressions early:
# GitHub Actions example
name: Test and Coverage
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install coverage pytest
- name: Run tests with coverage
run: |
coverage run -m pytest
coverage report --fail-under=80
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
This setup ensures coverage standards are maintained across all code changes.
In our next part, we’ll explore performance testing and load testing techniques. We’ll learn how to identify performance bottlenecks, simulate realistic user loads, and ensure your applications perform well under stress.