Skip to main content

Python Test Coverage

Introduction

Test coverage is a critical metric in software development that measures how much of your code is executed when your test suite runs. It helps identify untested parts of your codebase, which may contain hidden bugs or unexpected behavior. In Python, several tools allow you to assess and visualize your test coverage, helping you build more reliable applications.

In this tutorial, you'll learn:

  • What test coverage is and why it matters
  • How to measure coverage using popular Python tools
  • How to interpret coverage reports
  • Practical strategies to improve your test coverage

What is Test Coverage?

Test coverage (also called code coverage) is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs. High test coverage generally indicates a lower chance of containing undetected bugs and suggests the software has been thoroughly tested.

Coverage is typically measured in percentages across different aspects:

  • Line coverage: The percentage of code lines that were executed
  • Branch coverage: The percentage of branches (like if/else statements) that were evaluated
  • Function coverage: The percentage of functions that were called
  • Statement coverage: The percentage of statements that were executed

Setting Up Coverage Tools

The most popular coverage tool for Python is aptly named coverage, often used alongside testing frameworks like pytest.

Let's set up our environment:

bash
pip install pytest pytest-cov coverage

This installs:

  • pytest: The testing framework
  • pytest-cov: A pytest plugin for measuring coverage
  • coverage: The core coverage measurement library

Basic Coverage Example

Let's start with a simple example. Imagine we have a file called calculator.py with some basic arithmetic functions:

python
# calculator.py
def add(a, b):
return a + b

def subtract(a, b):
return a - b

def multiply(a, b):
return a * b

def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

Now, let's create a test file called test_calculator.py:

python
# test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide

def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0

def test_subtract():
assert subtract(5, 3) == 2
assert subtract(2, 5) == -3

def test_multiply():
assert multiply(2, 3) == 6
assert multiply(-1, 4) == -4

Notice we haven't written tests for the divide function. Let's run our tests with coverage to see the results:

bash
pytest --cov=calculator test_calculator.py

You might see output similar to this:

============================= test session starts ==============================
...
plugins: cov-4.1.0
collected 3 items

test_calculator.py ... [100%]

---------- coverage: platform linux, python 3.9.5-final-0 -----------
Name Stmts Miss Cover
-----------------------------------
calculator.py 8 2 75%
-----------------------------------
TOTAL 8 2 75%

============================== 3 passed in 0.02s ===============================

The output tells us we've covered 75% of the code, missing 2 statements. These missed statements are in the divide function that we didn't test.

Generating Detailed Coverage Reports

For a more detailed view, you can generate an HTML report:

bash
pytest --cov=calculator --cov-report=html test_calculator.py

This creates a htmlcov directory with an interactive HTML report. Open htmlcov/index.html in your browser to see which specific lines were not executed:

HTML Coverage Report

Improving Coverage: Testing Edge Cases

Let's improve our coverage by adding tests for the divide function, including the edge case of division by zero:

python
# Append this to test_calculator.py
def test_divide():
assert divide(6, 3) == 2
assert divide(5, 2) == 2.5

def test_divide_by_zero():
with pytest.raises(ValueError):
divide(5, 0)

Now when we run the coverage again:

bash
pytest --cov=calculator test_calculator.py

We should see 100% coverage:

Name            Stmts   Miss  Cover
-----------------------------------
calculator.py 8 0 100%
-----------------------------------
TOTAL 8 0 100%

Coverage Configuration

For larger projects, you might want to create a .coveragerc file in your project root to configure coverage settings:

ini
# .coveragerc
[run]
source = your_package_name
omit =
*/tests/*
*/venv/*

[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError

This configuration:

  • Specifies which package to measure
  • Omits test files and virtual environment from coverage measurement
  • Excludes certain lines (like boilerplate) from coverage requirements

Integrating Coverage with pytest

For more convenient testing, you can add coverage settings to your pytest.ini file:

ini
[pytest]
addopts = --cov=your_package_name --cov-report=term --cov-report=html

Now you can just run pytest and get coverage reports automatically.

Real-World Example: Testing a User Management System

Let's apply coverage to a more realistic example. Consider this simplified user management module:

python
# users.py
class UserManager:
def __init__(self):
self.users = {}

def add_user(self, username, email, role="user"):
if username in self.users:
raise ValueError(f"User {username} already exists")

if not self._is_valid_email(email):
raise ValueError("Invalid email format")

self.users[username] = {
"email": email,
"role": role,
"active": True
}
return True

def deactivate_user(self, username):
if username not in self.users:
return False

self.users[username]["active"] = False
return True

def get_user(self, username):
return self.users.get(username)

def _is_valid_email(self, email):
# Simplified email validation
return "@" in email and "." in email.split("@")[1]

Now let's write tests with coverage in mind:

python
# test_users.py
import pytest
from users import UserManager

@pytest.fixture
def user_manager():
return UserManager()

def test_add_user(user_manager):
# Test normal user addition
assert user_manager.add_user("john", "[email protected]")
assert user_manager.get_user("john") == {
"email": "[email protected]",
"role": "user",
"active": True
}

# Test custom role
assert user_manager.add_user("admin", "[email protected]", role="admin")
assert user_manager.get_user("admin")["role"] == "admin"

def test_add_duplicate_user(user_manager):
user_manager.add_user("bob", "[email protected]")

with pytest.raises(ValueError) as exc_info:
user_manager.add_user("bob", "[email protected]")

assert "already exists" in str(exc_info.value)

def test_invalid_email(user_manager):
with pytest.raises(ValueError) as exc_info:
user_manager.add_user("alice", "invalid-email")

assert "Invalid email" in str(exc_info.value)

def test_deactivate_user(user_manager):
user_manager.add_user("sam", "[email protected]")
assert user_manager.deactivate_user("sam")
assert not user_manager.get_user("sam")["active"]

def test_deactivate_nonexistent_user(user_manager):
assert not user_manager.deactivate_user("nobody")

def test_get_nonexistent_user(user_manager):
assert user_manager.get_user("nobody") is None

When we run coverage on these tests:

bash
pytest --cov=users test_users.py

We'd expect to see 100% coverage as we've tested all code paths including normal operations and error cases.

Common Coverage Pitfalls

1. 100% Coverage Doesn't Mean Bug-Free

Having 100% test coverage doesn't guarantee your code is error-free. It only means all lines are executed, not that all logical possibilities are tested.

2. Coverage ≠ Test Quality

Poor tests can achieve high coverage but miss critical bugs. Focus on testing behavior and edge cases, not just achieving a coverage percentage.

3. Diminishing Returns

The effort to increase coverage from 80% to 100% is often much greater than from 0% to 80%. Balance the cost/benefit ratio when pursuing higher coverage.

Best Practices for Test Coverage

  1. Set realistic coverage goals: For most projects, 80-90% is a good target.

  2. Focus on critical paths: Ensure business-critical code has thorough coverage.

  3. Don't exclude difficult code: Complex code often contains the most bugs and should be well-tested.

  4. Monitor coverage trends: Watch for drops in coverage that might indicate new untested code.

  5. Include coverage in CI/CD: Make coverage checks part of your continuous integration.

Summary

Test coverage is a valuable metric that helps you understand how thoroughly your Python code is being tested. In this guide, we explored:

  • How to set up and use coverage tools in Python
  • How to generate and interpret coverage reports
  • Techniques to improve coverage by testing edge cases
  • Real-world application of coverage in a user management system
  • Best practices and potential pitfalls

Remember that while high test coverage is desirable, the quality and thoroughness of tests are equally important. Aim for meaningful tests that verify your code works correctly under various conditions, not just tests that increase coverage percentages.

Additional Resources

Exercises

  1. Calculate the test coverage of an existing project you're working on.
  2. Identify untested code paths and write tests to improve coverage.
  3. Configure coverage to run automatically as part of your testing process.
  4. Create a custom .coveragerc file for a project with specific exclude patterns.
  5. Set up a GitHub Action or other CI tool to report coverage changes on pull requests.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)