Prerequisites
- Basic understanding of programming concepts ๐
- Python installation (3.8+) ๐
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write clean, Pythonic code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on test coverage with Coverage.py! ๐ In this guide, weโll explore how to measure and improve your test coverage, ensuring your code is thoroughly tested and reliable.
Youโll discover how Coverage.py can transform your testing strategy, giving you confidence that your code works as expected. Whether youโre building web applications ๐, data processing pipelines ๐, or Python libraries ๐, understanding test coverage is essential for writing robust, maintainable code.
By the end of this tutorial, youโll feel confident using Coverage.py to measure, analyze, and improve your test coverage! Letโs dive in! ๐โโ๏ธ
๐ Understanding Test Coverage
๐ค What is Test Coverage?
Test coverage is like a safety net for your code ๐ช. Think of it as a map showing which parts of your code have been tested and which havenโt. Itโs like checking if youโve painted every wall in a house - you want to make sure no spots are missed!
In Python terms, test coverage measures the percentage of your code that gets executed when you run your tests. This means you can:
- โจ Identify untested code paths
- ๐ Improve code reliability
- ๐ก๏ธ Catch bugs before production
๐ก Why Use Coverage.py?
Hereโs why developers love Coverage.py:
- Comprehensive Metrics ๐: See exactly which lines are tested
- Multiple Report Formats ๐: HTML, XML, JSON, and terminal output
- Branch Coverage ๐ณ: Track conditional logic testing
- Easy Integration ๐ง: Works with pytest, unittest, and more
Real-world example: Imagine building an e-commerce site ๐. With Coverage.py, you can ensure all payment processing paths are tested, preventing costly bugs in production!
๐ง Basic Syntax and Usage
๐ Installation and Setup
Letโs start by installing Coverage.py:
# ๐ Install Coverage.py
pip install coverage
# ๐จ Or install with pytest plugin
pip install pytest-cov
๐ฏ Basic Usage
Hereโs how to use Coverage.py with a simple example:
# ๐ calculator.py
class Calculator:
"""๐งฎ A simple calculator class"""
def add(self, a, b):
"""โ Add two numbers"""
return a + b
def subtract(self, a, b):
"""โ Subtract b from a"""
return a - b
def multiply(self, a, b):
"""โ๏ธ Multiply two numbers"""
return a * b
def divide(self, a, b):
"""โ Divide a by b"""
if b == 0:
raise ValueError("Cannot divide by zero! ๐ซ")
return a / b
# ๐ test_calculator.py
import pytest
from calculator import Calculator
class TestCalculator:
"""๐งช Test our calculator"""
def setup_method(self):
"""๐๏ธ Set up test calculator"""
self.calc = Calculator()
def test_add(self):
"""โ
Test addition"""
assert self.calc.add(2, 3) == 5
assert self.calc.add(-1, 1) == 0
def test_subtract(self):
"""โ
Test subtraction"""
assert self.calc.subtract(5, 3) == 2
assert self.calc.subtract(0, 5) == -5
Now run with coverage:
# ๐ Run tests with coverage
coverage run -m pytest test_calculator.py
# ๐ Generate coverage report
coverage report
# ๐จ Generate HTML report
coverage html
๐ก Explanation: Notice how weโre only testing add and subtract methods. Coverage.py will show us that multiply and divide are untested!
๐ก Practical Examples
๐ Example 1: E-commerce Order Processing
Letโs build a real-world example with comprehensive testing:
# ๐ order_processor.py
from datetime import datetime
from typing import List, Dict
class Order:
"""๐๏ธ Represents a customer order"""
def __init__(self, order_id: str, customer_id: str):
self.order_id = order_id
self.customer_id = customer_id
self.items: List[Dict] = []
self.status = "pending"
self.created_at = datetime.now()
self.discount = 0.0
def add_item(self, product_id: str, quantity: int, price: float):
"""โ Add item to order"""
if quantity <= 0:
raise ValueError("Quantity must be positive! ๐ฆ")
if price < 0:
raise ValueError("Price cannot be negative! ๐ฐ")
self.items.append({
"product_id": product_id,
"quantity": quantity,
"price": price
})
def apply_discount(self, percentage: float):
"""๐ Apply discount to order"""
if not 0 <= percentage <= 100:
raise ValueError("Discount must be between 0-100%! ๐ท๏ธ")
self.discount = percentage
def calculate_total(self) -> float:
"""๐ฐ Calculate order total with discount"""
subtotal = sum(item["quantity"] * item["price"]
for item in self.items)
discount_amount = subtotal * (self.discount / 100)
return subtotal - discount_amount
def process_payment(self, payment_amount: float) -> bool:
"""๐ณ Process payment for order"""
total = self.calculate_total()
if payment_amount < total:
return False
self.status = "paid"
return True
# ๐ test_order_processor.py
import pytest
from order_processor import Order
class TestOrderProcessor:
"""๐งช Comprehensive order processing tests"""
def test_order_creation(self):
"""โ
Test order initialization"""
order = Order("ORD123", "CUST456")
assert order.order_id == "ORD123"
assert order.customer_id == "CUST456"
assert order.status == "pending"
assert len(order.items) == 0
def test_add_valid_item(self):
"""โ
Test adding items"""
order = Order("ORD123", "CUST456")
order.add_item("PROD001", 2, 29.99)
assert len(order.items) == 1
assert order.items[0]["quantity"] == 2
def test_add_invalid_item(self):
"""โ Test invalid item additions"""
order = Order("ORD123", "CUST456")
# Test negative quantity
with pytest.raises(ValueError, match="Quantity must be positive"):
order.add_item("PROD001", -1, 29.99)
# Test negative price
with pytest.raises(ValueError, match="Price cannot be negative"):
order.add_item("PROD001", 1, -10.00)
def test_apply_discount(self):
"""๐ Test discount application"""
order = Order("ORD123", "CUST456")
order.add_item("PROD001", 2, 50.00)
order.apply_discount(10)
assert order.discount == 10
assert order.calculate_total() == 90.00
def test_invalid_discount(self):
"""โ Test invalid discounts"""
order = Order("ORD123", "CUST456")
with pytest.raises(ValueError, match="Discount must be between"):
order.apply_discount(150)
def test_process_payment_success(self):
"""๐ณ Test successful payment"""
order = Order("ORD123", "CUST456")
order.add_item("PROD001", 1, 100.00)
result = order.process_payment(100.00)
assert result is True
assert order.status == "paid"
def test_process_payment_insufficient(self):
"""โ Test insufficient payment"""
order = Order("ORD123", "CUST456")
order.add_item("PROD001", 1, 100.00)
result = order.process_payment(50.00)
assert result is False
assert order.status == "pending"
Run coverage and see the results:
# ๐ Run with branch coverage
coverage run --branch -m pytest test_order_processor.py
# ๐ Detailed report
coverage report -m
# ๐จ HTML report with highlighting
coverage html
๐ฏ Try it yourself: Add tests for edge cases like empty orders or multiple discounts!
๐ฎ Example 2: Game Character Testing
Letโs create a fun game character system with comprehensive coverage:
# ๐ game_character.py
import random
from typing import List, Optional
class Character:
"""๐ฎ RPG game character"""
def __init__(self, name: str, character_class: str):
self.name = name
self.character_class = character_class
self.level = 1
self.health = 100
self.max_health = 100
self.experience = 0
self.skills: List[str] = []
self.inventory: List[str] = []
# ๐ฏ Set class-specific attributes
self._initialize_class()
def _initialize_class(self):
"""๐๏ธ Initialize class-specific attributes"""
if self.character_class == "warrior":
self.attack = 15
self.defense = 10
self.skills = ["โ๏ธ Slash", "๐ก๏ธ Block"]
elif self.character_class == "mage":
self.attack = 20
self.defense = 5
self.skills = ["๐ฅ Fireball", "โ๏ธ Ice Shield"]
elif self.character_class == "rogue":
self.attack = 12
self.defense = 8
self.skills = ["๐ก๏ธ Sneak Attack", "๐จ Dodge"]
else:
raise ValueError(f"Unknown class: {self.character_class}")
def take_damage(self, damage: int):
"""๐ Take damage"""
actual_damage = max(0, damage - self.defense)
self.health = max(0, self.health - actual_damage)
return actual_damage
def heal(self, amount: int):
"""๐ Heal character"""
self.health = min(self.max_health, self.health + amount)
def gain_experience(self, exp: int):
"""โญ Gain experience and level up"""
self.experience += exp
# Level up every 100 exp
while self.experience >= self.level * 100:
self.level_up()
def level_up(self):
"""๐ Level up character"""
self.level += 1
self.max_health += 10
self.health = self.max_health
self.attack += 2
self.defense += 1
# ๐ Learn new skill every 3 levels
if self.level % 3 == 0:
self.learn_skill()
def learn_skill(self):
"""โจ Learn a new skill"""
new_skills = {
"warrior": ["๐ช Power Strike", "๐จ Earthquake"],
"mage": ["โก Lightning", "๐ช๏ธ Tornado"],
"rogue": ["๐ฏ Precision Strike", "๐ซ๏ธ Smoke Bomb"]
}
available = [s for s in new_skills.get(self.character_class, [])
if s not in self.skills]
if available:
self.skills.append(random.choice(available))
def is_alive(self) -> bool:
"""โค๏ธ Check if character is alive"""
return self.health > 0
# ๐ test_game_character.py
import pytest
from unittest.mock import patch
from game_character import Character
class TestGameCharacter:
"""๐งช Test our game character system"""
@pytest.mark.parametrize("char_class,expected_attack,expected_defense", [
("warrior", 15, 10),
("mage", 20, 5),
("rogue", 12, 8)
])
def test_character_creation(self, char_class, expected_attack, expected_defense):
"""โ
Test character initialization"""
char = Character("Hero", char_class)
assert char.name == "Hero"
assert char.level == 1
assert char.attack == expected_attack
assert char.defense == expected_defense
def test_invalid_class(self):
"""โ Test invalid character class"""
with pytest.raises(ValueError, match="Unknown class"):
Character("Hero", "ninja")
def test_take_damage(self):
"""๐ Test damage calculation"""
warrior = Character("Tank", "warrior")
# Damage reduced by defense
damage_taken = warrior.take_damage(20)
assert damage_taken == 10 # 20 - 10 defense
assert warrior.health == 90
# Minimum damage is 0
damage_taken = warrior.take_damage(5)
assert damage_taken == 0
assert warrior.health == 90
def test_heal(self):
"""๐ Test healing"""
mage = Character("Wizard", "mage")
mage.health = 50
# Normal healing
mage.heal(30)
assert mage.health == 80
# Can't exceed max health
mage.heal(50)
assert mage.health == 100
def test_level_up(self):
"""๐ Test leveling system"""
rogue = Character("Shadow", "rogue")
initial_attack = rogue.attack
# Gain experience and level up
rogue.gain_experience(100)
assert rogue.level == 2
assert rogue.max_health == 110
assert rogue.health == 110 # Fully healed on level up
assert rogue.attack == initial_attack + 2
@patch('random.choice')
def test_learn_skill(self, mock_choice):
"""โจ Test skill learning"""
mock_choice.return_value = "๐ช Power Strike"
warrior = Character("Knight", "warrior")
# Level up to 3 to learn skill
warrior.gain_experience(200) # Level 3
assert "๐ช Power Strike" in warrior.skills
def test_is_alive(self):
"""โค๏ธ Test alive status"""
char = Character("Hero", "warrior")
assert char.is_alive() is True
char.health = 0
assert char.is_alive() is False
# ๐ .coveragerc - Configuration file
[run]
source = .
omit =
*/tests/*
*/test_*
setup.py
[report]
precision = 2
show_missing = True
skip_covered = False
[html]
directory = htmlcov
[xml]
output = coverage.xml
๐ Advanced Concepts
๐งโโ๏ธ Branch Coverage
Branch coverage tracks whether both paths of conditional statements are tested:
# ๐ advanced_coverage.py
def calculate_grade(score: int) -> str:
"""๐ Calculate letter grade with complex logic"""
if score < 0 or score > 100:
raise ValueError("Score must be 0-100! ๐")
# ๐ฏ Multiple branches to test
if score >= 90:
if score >= 97:
return "A+"
elif score >= 93:
return "A"
else:
return "A-"
elif score >= 80:
if score >= 87:
return "B+"
elif score >= 83:
return "B"
else:
return "B-"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
# Test with branch coverage
# coverage run --branch -m pytest
๐๏ธ Coverage Configuration
Create a comprehensive .coveragerc
file:
# ๐ .coveragerc
[run]
branch = True
source = .
omit =
*/site-packages/*
*/tests/*
setup.py
*/migrations/*
*/venv/*
[report]
# Precision for coverage percentages
precision = 2
# Show lines not covered
show_missing = True
# Don't show files with 100% coverage
skip_covered = False
# Exclude lines from coverage
exclude_lines =
# Don't complain about missing debug-only code:
def __repr__
if self\.debug
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:
# Don't complain about abstract methods
@abstract
[html]
directory = htmlcov
title = My Project Coverage
[xml]
output = coverage.xml
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Testing Only Happy Paths
# โ Wrong - only testing success cases
def test_divide_only_success():
calc = Calculator()
assert calc.divide(10, 2) == 5
# โ
Correct - test edge cases too!
def test_divide_comprehensive():
calc = Calculator()
# Happy path
assert calc.divide(10, 2) == 5
# Edge cases
assert calc.divide(0, 5) == 0
assert calc.divide(-10, 2) == -5
# Error case
with pytest.raises(ValueError):
calc.divide(10, 0)
๐คฏ Pitfall 2: Ignoring Branch Coverage
# โ Incomplete - missing branch coverage
def test_process_incomplete():
result = process_data([1, 2, 3])
assert result is not None
# โ
Complete - all branches covered
def test_process_comprehensive():
# Test with data
result = process_data([1, 2, 3])
assert result == 6
# Test empty list branch
result = process_data([])
assert result is None
# Test None input branch
result = process_data(None)
assert result is None
๐ ๏ธ Best Practices
- ๐ฏ Aim for 80%+ Coverage: But remember, 100% isnโt always necessary
- ๐ Use Branch Coverage: Enable
--branch
to catch conditional logic - ๐ซ Exclude Appropriately: Donโt test auto-generated or third-party code
- ๐ Continuous Integration: Run coverage in CI/CD pipelines
- ๐ Track Trends: Monitor coverage over time
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Banking System with Full Coverage
Create a banking system with comprehensive test coverage:
๐ Requirements:
- โ Account creation with initial balance
- ๐ฐ Deposit and withdrawal operations
- ๐ Transfer between accounts
- ๐ Transaction history
- ๐ฏ Interest calculation
- ๐ก๏ธ Overdraft protection
๐ Bonus Points:
- Achieve 95%+ test coverage
- Include branch coverage
- Test all error conditions
- Add performance benchmarks
๐ก Solution
๐ Click to see solution
# ๐ banking_system.py
from datetime import datetime
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class Transaction:
"""๐ณ Represents a bank transaction"""
timestamp: datetime
type: str # deposit, withdrawal, transfer
amount: float
balance_after: float
description: str
class BankAccount:
"""๐ฆ Bank account with full features"""
def __init__(self, account_number: str, owner: str,
initial_balance: float = 0.0):
if initial_balance < 0:
raise ValueError("Initial balance cannot be negative! ๐ธ")
self.account_number = account_number
self.owner = owner
self.balance = initial_balance
self.transactions: List[Transaction] = []
self.overdraft_limit = 0.0
self.interest_rate = 0.02 # 2% annual
if initial_balance > 0:
self._add_transaction("deposit", initial_balance,
"Initial deposit ๐")
def deposit(self, amount: float) -> float:
"""๐ฐ Deposit money"""
if amount <= 0:
raise ValueError("Deposit amount must be positive! ๐ต")
self.balance += amount
self._add_transaction("deposit", amount, "Deposit ๐ฐ")
return self.balance
def withdraw(self, amount: float) -> float:
"""๐ธ Withdraw money"""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive! ๐ต")
max_withdrawal = self.balance + self.overdraft_limit
if amount > max_withdrawal:
raise ValueError(f"Insufficient funds! Max: ${max_withdrawal:.2f} ๐ซ")
self.balance -= amount
self._add_transaction("withdrawal", amount, "Withdrawal ๐ธ")
return self.balance
def transfer(self, recipient: 'BankAccount', amount: float):
"""๐ Transfer to another account"""
if recipient == self:
raise ValueError("Cannot transfer to same account! ๐")
# Withdraw from sender
self.withdraw(amount)
# Deposit to recipient
recipient.deposit(amount)
# Update transaction descriptions
self.transactions[-1].description = f"Transfer to {recipient.account_number} ๐ค"
recipient.transactions[-1].description = f"Transfer from {self.account_number} ๐ฅ"
def calculate_interest(self) -> float:
"""๐ Calculate monthly interest"""
if self.balance <= 0:
return 0.0
monthly_rate = self.interest_rate / 12
interest = self.balance * monthly_rate
self.balance += interest
self._add_transaction("interest", interest, "Monthly interest ๐")
return interest
def get_statement(self, limit: Optional[int] = None) -> List[Transaction]:
"""๐ Get transaction history"""
if limit:
return self.transactions[-limit:]
return self.transactions.copy()
def set_overdraft_limit(self, limit: float):
"""๐ก๏ธ Set overdraft protection limit"""
if limit < 0:
raise ValueError("Overdraft limit cannot be negative! ๐ซ")
self.overdraft_limit = limit
def _add_transaction(self, trans_type: str, amount: float,
description: str):
"""๐ Record transaction"""
transaction = Transaction(
timestamp=datetime.now(),
type=trans_type,
amount=amount,
balance_after=self.balance,
description=description
)
self.transactions.append(transaction)
# ๐ test_banking_system.py
import pytest
from banking_system import BankAccount, Transaction
class TestBankingSystem:
"""๐งช Comprehensive banking system tests"""
def test_account_creation(self):
"""โ
Test account initialization"""
# With initial balance
account = BankAccount("ACC001", "Alice", 1000.0)
assert account.balance == 1000.0
assert len(account.transactions) == 1
# Without initial balance
account2 = BankAccount("ACC002", "Bob")
assert account2.balance == 0.0
assert len(account2.transactions) == 0
def test_invalid_initial_balance(self):
"""โ Test negative initial balance"""
with pytest.raises(ValueError, match="Initial balance cannot be negative"):
BankAccount("ACC001", "Alice", -100.0)
def test_deposit(self):
"""๐ฐ Test deposits"""
account = BankAccount("ACC001", "Alice")
# Valid deposit
new_balance = account.deposit(500.0)
assert new_balance == 500.0
assert len(account.transactions) == 1
# Multiple deposits
account.deposit(250.0)
assert account.balance == 750.0
def test_invalid_deposit(self):
"""โ Test invalid deposits"""
account = BankAccount("ACC001", "Alice")
with pytest.raises(ValueError, match="Deposit amount must be positive"):
account.deposit(0)
with pytest.raises(ValueError, match="Deposit amount must be positive"):
account.deposit(-50)
def test_withdrawal(self):
"""๐ธ Test withdrawals"""
account = BankAccount("ACC001", "Alice", 1000.0)
# Valid withdrawal
new_balance = account.withdraw(300.0)
assert new_balance == 700.0
# Withdraw remaining
account.withdraw(700.0)
assert account.balance == 0.0
def test_overdraft_protection(self):
"""๐ก๏ธ Test overdraft"""
account = BankAccount("ACC001", "Alice", 100.0)
account.set_overdraft_limit(50.0)
# Within overdraft limit
account.withdraw(140.0)
assert account.balance == -40.0
# Exceeding overdraft
with pytest.raises(ValueError, match="Insufficient funds"):
account.withdraw(20.0)
def test_transfer(self):
"""๐ Test transfers"""
sender = BankAccount("ACC001", "Alice", 1000.0)
recipient = BankAccount("ACC002", "Bob", 500.0)
sender.transfer(recipient, 300.0)
assert sender.balance == 700.0
assert recipient.balance == 800.0
# Check transaction descriptions
assert "Transfer to ACC002" in sender.transactions[-1].description
assert "Transfer from ACC001" in recipient.transactions[-1].description
def test_self_transfer(self):
"""โ Test self-transfer"""
account = BankAccount("ACC001", "Alice", 1000.0)
with pytest.raises(ValueError, match="Cannot transfer to same account"):
account.transfer(account, 100.0)
def test_interest_calculation(self):
"""๐ Test interest"""
account = BankAccount("ACC001", "Alice", 1000.0)
interest = account.calculate_interest()
expected_interest = 1000.0 * 0.02 / 12
assert abs(interest - expected_interest) < 0.01
assert account.balance > 1000.0
# No interest on zero balance
empty_account = BankAccount("ACC002", "Bob")
assert empty_account.calculate_interest() == 0.0
def test_statement(self):
"""๐ Test statement generation"""
account = BankAccount("ACC001", "Alice", 1000.0)
account.deposit(500.0)
account.withdraw(200.0)
# Full statement
full_statement = account.get_statement()
assert len(full_statement) == 3
# Limited statement
limited_statement = account.get_statement(limit=2)
assert len(limited_statement) == 2
assert limited_statement[0].type == "deposit"
assert limited_statement[1].type == "withdrawal"
# Run with coverage
# coverage run --branch -m pytest test_banking_system.py -v
# coverage report -m
# coverage html
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Install and configure Coverage.py with confidence ๐ช
- โ Measure test coverage for your Python projects ๐
- โ Identify untested code and improve coverage ๐ฏ
- โ Use branch coverage to catch all code paths ๐ณ
- โ Generate beautiful HTML reports to share with your team ๐จ
Remember: High test coverage doesnโt guarantee bug-free code, but itโs a powerful tool for building reliable software! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered test coverage with Coverage.py!
Hereโs what to do next:
- ๐ป Practice with the banking system exercise above
- ๐๏ธ Add coverage measurement to your existing projects
- ๐ Move on to our next tutorial on debugging techniques
- ๐ Set up coverage tracking in your CI/CD pipeline!
Remember: Good test coverage is like a safety net for your code. Keep testing, keep measuring, and most importantly, have fun building reliable software! ๐
Happy testing! ๐๐โจ