19.11. Test Coverage
Table of Contents
Why Test Coverage Matters
Test coverage measures how much of your code is executed when you run your automated tests. It is not about how βgoodβ your tests are, but about how much code they touch.
You care about it because:
- It shows which parts of the code are never tested.
- It helps prevent regressions, since covered code is more likely to break loudly.
- It guides you on where to add tests next.
Important: High coverage does not guarantee high quality tests. It only guarantees that code lines are executed during tests, not that behavior is fully checked.
Typical uses:
- Before refactoring, to check if the area is tested at all.
- In code reviews, to see if new code came with tests.
- In CI pipelines, to enforce a minimum coverage threshold.
Types of Test Coverage
Different tools and languages have different metrics, but you will often see:
| Type | What it measures | Example |
|---|---|---|
| Line coverage | Percentage of executed lines of code | Line 15β40 of user.py were run |
| Branch coverage | Whether each branch of conditionals was taken | Both if and else blocks executed |
| Function coverage | Whether each function was called | create_user() was called during tests |
| Statement coverage | Similar to line coverage at statement level | All statements in a block were executed |
For beginners, line coverage is usually enough.
Rule of thumb: Start by tracking line coverage. Once you are comfortable, use branch coverage for critical logic, like payment handling or permissions.
Measuring Test Coverage with pytest and coverage.py
In Python, the most common way to measure coverage is with the coverage.py tool, often used together with pytest.
Installing coverage.py
Assume you already have pytest installed.
pip install coverage
You will then use the coverage command-line tool.
Basic usage
Imagine this project:
my_app/
app/
__init__.py
math_utils.py
tests/
test_math_utils.py
app/math_utils.py:
def add(a, b):
return a + b
def sub(a, b):
return a - b
def div(a, b):
if b == 0:
raise ValueError("Division by zero")
return a / b
tests/test_math_utils.py:
from app.math_utils import add, div
def test_add():
assert add(2, 3) == 5
def test_div():
assert div(10, 2) == 5Now run tests with coverage measurement:
coverage run -m pytest
coverage reportSample output:
Name Stmts Miss Cover
----------------------------------------
app/math_utils.py 8 2 75%
----------------------------------------
TOTAL 8 2 75%Explanation:
Stmtsis the number of statements.Missis how many were not executed by tests.Coveris the percentage of executed statements.
You can also generate an HTML report:
coverage html
Then open htmlcov/index.html in a browser to see color highlighted lines:
- Green: executed during tests.
- Red: never executed.
- Yellow: partially executed.
This visual view is very helpful to see which branches you miss.
Understanding Line and Branch Coverage with Examples
Line coverage example
Extend div:
def div(a, b):
if b == 0:
raise ValueError("Division by zero")
return a / b
Your tests only call div(10, 2). When you check coverage:
- The line
if b == 0:is executed. - The
return a / bline is executed. - The
raise ValueError(...)line is never executed.
So you have partial coverage for this function. Lines inside the exception path are not covered.
Add another test:
import pytest
from app.math_utils import div
def test_div_by_zero():
with pytest.raises(ValueError):
div(10, 0)
Run coverage again. Now all lines inside div are executed. Line coverage for that file increases.
Branch coverage example
Branch coverage looks deeper. It wants each branch of a decision to be taken. For simple if:
def is_even(x):
if x % 2 == 0:
return True
return FalseBranches:
- Condition is true, returns
True. - Condition is false, returns
False.
If you only test is_even(2), you have:
- Line coverage: both lines in the
ifand thereturn Falseline are executed? Actually no: thereturn Falseline is not executed. Only lines up to thereturn Trueare hit.
Better tests:
def test_is_even_true():
assert is_even(2) is True
def test_is_even_false():
assert is_even(3) is FalseNow both branches are covered.
For nested conditions, this becomes more important, for example:
def grade(score):
if score < 0 or score > 100:
raise ValueError("Invalid")
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"To cover all branches properly, you would add tests for:
- invalid score
- score in each grade band: 95, 85, 75, 65, 50
That is realistic for business logic, like pricing or permissions, where missing a branch can cause serious bugs.
Configuring Coverage for a Backend Project
Backend projects usually have some structure, for example:
my_service/
app/
__init__.py
main.py
api/
__init__.py
users.py
auth.py
core/
config.py
security.py
tests/
test_users.py
test_auth.py
pyproject.toml or setup.cfg or .coveragercYou often want:
- To include only your application code, not third-party libraries.
- To omit test files from coverage.
- To ignore small utility or generated files.
Using `.coveragerc`
Create a .coveragerc file in the project root:
[run]
source = app
omit =
app/__init__.py
app/main.py
[report]
exclude_lines =
pragma: no cover
if __name__ == "__main__":
passExplanation:
source = apptells coverage to track only theapppackage.omitlists files to ignore.exclude_lineslets you skip certain lines from coverage statistics.
Then run:
coverage run -m pytest
coverage reportUsing pyproject.toml
If your project uses pyproject.toml, you can also configure coverage there:
[tool.coverage.run]
source = ["app"]
[tool.coverage.report]
omit = [
"app/__init__.py",
"app/main.py",
]
exclude_lines = [
"pragma: no cover",
"if __name__ == \"__main__\":",
]The behavior is similar. The configuration format is just different.
Rule: Always configure source so that coverage only measures your code, not libraries or the tests themselves.
What Coverage Percentage Should You Aim For?
There is no single correct number. Typical ranges:
| Coverage | Interpretation |
|---|---|
| 0β30% | Many parts are untested, high risk of hidden bugs |
| 30β60% | Some coverage, but many critical paths likely untested |
| 60β80% | Reasonable for many real projects |
| 80β90% | Good coverage for serious backends |
| 90β100% | Very high coverage, but can be expensive to maintain |
A common goal for backend services is 80% or higher, but it depends on context.
Examples:
- A learning project: 60β70% might be fine while you focus on concepts.
- A payment microservice: You might target 90%+ on the core domain logic.
- A startup MVP: Maybe 70β80%, with extra tests around critical features.
Guideline: Use coverage as a guide, not as a target to game. It is better to have 75% meaningful tests than 95% coverage full of useless assertions.
Coverage Pitfalls and How to Avoid Them
Pitfall 1: Writing tests that only increase coverage numbers
Example of a bad test:
from app.math_utils import add
def test_add_does_not_crash():
add(2, 3)This increases coverage, but it does not verify any behavior.
Improve it:
def test_add_returns_sum():
assert add(2, 3) == 5Rule of thumb: Each test should assert something important.
Pitfall 2: Forcing 100% coverage everywhere
Some code is hard or not worth testing directly, for example:
- Logging wrappers.
- Very simple one-line helper functions.
- Code tightly coupled to external systems that you can better test via higher-level integration tests.
In these cases, you can mark lines or blocks to be ignored:
def debug_log(message):
print(message) # pragma: no cover
Or in config, use exclude_lines for things like if __name__ == "__main__":.
Use this sparingly. Hide only code that is truly not worth direct tests.
Pitfall 3: Ignoring untested error paths
Common in backend code:
def get_user_by_id(db, user_id):
user = db.get(user_id)
if user is None:
raise UserNotFoundError()
return userYou might only test the success case and ignore the error path:
def test_get_user_by_id_found(db_session):
user = create_user(db_session)
assert get_user_by_id(db_session, user.id) == user
Now coverage shows that the raise UserNotFoundError() line is untested. This is risky, because error handling is critical in backends.
Add an explicit test:
import pytest
def test_get_user_by_id_not_found(db_session):
with pytest.raises(UserNotFoundError):
get_user_by_id(db_session, 9999)This makes your error paths safer and increases coverage meaningfully.
Enforcing Coverage in CI
In real projects, you often run tests with coverage in a CI pipeline (like GitHub Actions or GitLab CI) and enforce a minimum percentage.
Example: Minimum coverage threshold
You can fail the test run if coverage is too low:
coverage run -m pytest
coverage report --fail-under=80If total coverage is below 80%, the command exits with a non-zero code and the CI job fails.
You can also check coverage per file:
coverage report --fail-under=80 --skip-coveredThis prints only files below 100% coverage and fails if total is below 80%.
Simple GitHub Actions example
name: CI
on:
push:
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: |
coverage run -m pytest
coverage report --fail-under=80Now every pull request must keep coverage above 80%, or developers must add tests.
Rule: Use a realistic threshold that your team can maintain. It is better to start with 60β70% and increase later than to set 95% and force bad tests.
Using Coverage to Guide Your Testing
Coverage tools tell you where you are not testing yet. You can use that information to plan new tests.
A simple workflow:
- Run tests with coverage and open the HTML report.
- Look for red areas in critical parts of the code, such as:
- Authentication logic.
- Database transactions.
- External service integration wrappers.
- Write tests for those untested branches or functions.
- Run coverage again and repeat.
Example on a small API endpoint:
# app/api/users.py
from fastapi import APIRouter, HTTPException
router = APIRouter()
@router.get("/users/{user_id}")
def get_user(user_id: int):
if user_id <= 0:
raise HTTPException(status_code=400, detail="Invalid id")
user = fetch_user_from_db(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return userCoverage report might show:
- Branch for
user_id <= 0not covered. - Branch for
not usernot covered.
So you add tests:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_get_user_invalid_id():
response = client.get("/users/0")
assert response.status_code == 400
def test_get_user_not_found(monkeypatch):
def fake_fetch_user_from_db(user_id: int):
return None
monkeypatch.setattr("app.api.users.fetch_user_from_db", fake_fetch_user_from_db)
response = client.get("/users/123")
assert response.status_code == 404Now both error branches should appear as covered in the report. This improves both safety and measured coverage.
Summary
- Test coverage tells you which lines and branches are executed by tests.
- Use coverage.py with
pytestto measure and report coverage. - Configure coverage to only track your application code.
- Aim for a reasonable coverage target, often around 80%, but prioritize meaningful tests over chasing 100%.
- Use coverage reports to find:
- Untested functions.
- Missing tests for error handling and edge cases.
- Enforce minimum coverage in CI with
coverage report --fail-under=....
Coverage is not the goal itself. It is a tool to help you write safer, more reliable backend code.
Views: 7
KAHIBARO