Production engineering guide for test coverage strategies covering patterns, implementation strategies, and operational best practices.
Test coverage strategies define how and what parts of a system are tested, ensuring that code changes, infrastructure updates, and integration points are validated with appropriate depth. They matter most when teams face increasing complexity, frequent releases, and the risk of undetected regressions across services, databases, and user workflows. This page details the practices, decisions, and trade-offs in designing and maintaining effective test coverage, with no assumptions about platform or tooling.
Coverage strategies begin with clear intent: *which parts of the system are worth testing, and how thoroughly?* The goal is to answer: *What do we consider “tested” for a change to be considered ready?* The answer shapes test selection, execution, and reporting.
Coverage is measured per layer: unit, integration, service, end-to-end, and system. Each layer has distinct criteria, but the strategy defines how they interlock.
Coverage is not just about *what* is tested, but *how much* is tested. The strategy defines thresholds: e.g., “all critical functions must have 80% line coverage” or “every new API endpoint must be exercised in at least one E2E test.”
When a team runs tests on a pull request, they must decide which tests to execute. This decision hinges on the *coverage strategy*, not just convenience.
The most comprehensive approach is to run all tests on every merge to main. This ensures the entire codebase is validated after every change.
make test-all
make test-all is defined in Makefile:
.PHONY: test-all test-unit test-integration test-e2e test-system
test-all: test-unit test-integration test-e2e test-system
test-unit:
pytest tests/unit -v --cov=src --cov-report=html
test-integration:
pytest tests/integration -v --cov=src --cov-report=html --cov-append
test-e2e:
cypress run --config video=true,reporter=spec
test-system:
k6 run scripts/load-test.js --out=influxdb
This approach ensures high signal but is slow. A failure in test-system may delay a release by 30 minutes.
A more efficient strategy is to run only the tests affected by changes. This requires defining *test boundaries* and *change detection*.
# Detect changed files and run relevant tests
git diff --name-only HEAD~1 HEAD | grep -E '\.(py|js|json|yaml)$' | xargs -I{} bash -c '
case {} in
src/*/service/*.py) pytest tests/integration/$(dirname {})/$(basename {} .py)_test.py ;;
src/*/api/*.py) pytest tests/integration/$(dirname {})/$(basename {} .py)_test.py ;;
tests/e2e/*.js) cypress run --spec tests/e2e/$(basename {} .js) ;;
*) pytest tests/unit/$(basename {} .py)_test.py ;;
esac
'
This script uses git diff to find modified files and routes them to appropriate test suites. It relies on consistent naming: src/payment/service.py maps to tests/integration/payment/service_test.py.
The strategy fails silently when files are moved without updating test files. A change to src/user/api.py may not trigger tests/integration/user/api_test.py if the file was renamed but not updated.
A common pattern is to run tests in tiers: fast, medium, slow.
Each tier is run at different points:
main merge# .github/workflows/ci.yml
name: CI
on: [pull_request, push]
jobs:
test-fast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: make test-unit test-integration
- run: echo "Fast tests passed"
test-medium:
needs: test-fast
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- run: make test-e2e
- run: echo "Medium tests passed"
test-slow:
needs: test-medium
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- run: make test-system
- run: echo "Slow tests passed"
This setup ensures that PRs get fast feedback, while release candidates are validated deeply.
Coverage metrics guide strategy refinement and tooling. The most common metrics are line coverage, branch coverage, and function coverage.
Line coverage measures how many lines of code are executed during tests.
# Example: coverage report output from pytest with coverage.py
Name Stmts Miss Cover Missing
-------------------------------------------
src/user/__init__.py 10 2 80% 15, 21
src/user/model.py 45 5 89% 88, 92, 101, 105, 113
src/user/service.py 88 12 86% 24, 35, 47, 52, 61, 70, 78, 85, 91, 102, 110, 118
-------------------------------------------
TOTAL 143 19 87%
The Missing column shows lines not executed. A test may miss a raise ValidationError in model.py, which is critical but not covered.
Branch coverage tracks whether every branch (e.g., if, while, case) is taken.
# Run pytest with branch coverage
pytest --cov=src --cov-report=html --cov-branch tests/
Branch coverage detects untested paths:
if user.is_active: ... without testing False casewhile queue: ... with empty queueA test suite may achieve 90% line coverage but only 75% branch coverage, revealing untested logic.
Function coverage counts how many functions are called.
# In pytest, use pytest-cov with function coverage
pytest --cov=src --cov-report=html --cov-fail-under=85
This fails the CI if function coverage drops below 85%. A function send_notification() may be defined but never called in any test.
Coverage strategies must address *which tests to write* and *when to add new ones*. A test is valuable not just for coverage, but for its *cost to maintain* and *signal to the team*.
Focus on testing the critical path — the sequence of operations users rely on most.
For a checkout flow:
A test should cover all six steps, with data setup in beforeEach and validation in afterEach.
// cypress/integration/checkout.spec.js
describe('Checkout flow', () => {
beforeEach(() => {
cy.seedDatabase('checkout-seed.json');
cy.login('[email protected]', 'password');
cy.visit('/cart');
});
it('completes a full checkout with credit card', () => {
cy.get('[data-cy="add-to-cart"]').click();
cy.get('[data-cy="proceed-to-checkout"]').click();
cy.get('[data-cy="shipping-name"]').type('Alice Johnson');
cy.get('[data-cy="shipping-address"]').type('123 Main St');
cy.get('[data-cy="payment-method"]').select('credit-card');
cy.get('[data-cy="submit-payment"]').click();
cy.get('[data-cy="confirmation-message"]').should('contain', 'Thank you');
cy.get('[data-cy="email-sent"]').should('be.visible');
});
});
This test is high-value but must be kept up-to-date. If the shipping-name field is renamed to shipping-name-input, the test fails silently unless the team checks the cypress logs.
Mutation testing evaluates test quality by introducing small changes (mutations) to code and checking if tests catch them.
# Run mutation testing with pitest (Java), mutmut (Python), or mutant (Haskell)
mutmut run
mutmut show
Output:
Mutations: 123
Covered: 98
Uncovered: 25
Coverage: 80%
A mutation that replaces if x > 0 with if x >= 0 is not caught by any test — indicating a gap in coverage.
Coverage strategies use *coverage thresholds* to gate progress.
# .github/coverage.yml
coverage:
thresholds:
line: 85
branch: 75
function: 80
report:
- html
- json
fail: true
When a PR updates src/user/service.py, the CI checks:
pytest --cov=src --cov-report=html --cov-branch --cov-fail-under=85
If line coverage drops below 85%, the PR is marked as failing. The team can then add tests to improve coverage.
A test suite runs, but the coverage report is not published to a shared location.
# After running tests, publish coverage to S3
aws s3 cp ./htmlcov/ s3://artifacts/coverage/latest/ --recursive
Without this, the team runs tests but never sees the coverage report. A new function is added, but coverage is not updated in the CI dashboard.
Teams often confuse line coverage with branch coverage. A test suite may report 90% line coverage, but the if statements in model.py are not tested for both True and False paths.
A common mistake: running pytest --cov=src but forgetting --cov-branch.
Multiple test suites may test the same function, leading to redundancy.
tests/unit/user/test_model.py tests User.create()tests/integration/user/test_service.py also tests User.create()cypress/integration/user/signup.spec.js includes User.create()The function is tested in three places, but changes to it may require updates in all three files. This creates test debt.
Tests rely on data, but data setup is often inconsistent.
// tests/data/users.json
[
{
"id": 1,
"name": "Alice",
"active": true
}
]
But cypress/integration/user/login.spec.js assumes a user with id: 2, name: Bob, active: false. The test passes locally but fails in CI because the seed data is missing.
Some tests use dependencies not in production.
test-integration uses mockserver to simulate a payment servicetest-e2e uses cypress with chrome and video enabledtest-system uses k6 with influxdb for metricsBut the test-e2e pipeline does not start mockserver, so the test fails when the payment service is down.
A test coverage strategy is not a static configuration but an active practice. It defines what is tested, how tests are selected, and how coverage is measured and maintained. The strategy must be visible, actionable, and self-enforcing. Teams that refine their coverage strategy see fewer regressions, faster release cycles, and clearer ownership of quality.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy