Enforcing Test Coverage Thresholds with SonarQube and Codecov
Coverage thresholds block merges when code coverage drops below your team's standard. SonarQube enforces thresholds as quality gate conditions; Codecov enforces them via a codecov.yml file. This guide covers both approaches, how to set realistic thresholds, and how to increase them over time.
Why Coverage Thresholds Matter
Coverage metrics without enforcement are suggestions. A dashboard showing 62% coverage that no one looks at doesn't improve quality. Coverage thresholds that block PR merges force the team to either:
- Write tests for new code before merging
- Explicitly acknowledge that new code is untested (and accept the risk)
Neither outcome is optional — the decision is forced at PR review time rather than deferred indefinitely.
The challenge is setting thresholds that are achievable today while creating a ratchet effect toward higher coverage over time.
Approach 1: SonarQube Coverage Gates
SonarQube's quality gate can enforce coverage on new code separately from overall coverage. This is the most important distinction: "coverage on new code ≥ 80%" is much more achievable than "overall coverage ≥ 80%" on a legacy codebase.
Setting Up Coverage Ingestion
First, ensure tests generate a coverage report that SonarQube can read:
Python (pytest-cov):
pytest --cov=src --cov-report=xml:coverage.xmlJavaScript (Jest):
jest --coverage --coverageReporters=lcovJava (JaCoCo):
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin><!-- Report path in sonar-project.properties -->
sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml.NET (Coverlet):
dotnet test --collect:<span class="hljs-string">"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=coberturasonar.cs.opencover.reportsPaths=**/TestResults/**/*.xmlConfiguring the Quality Gate
In SonarQube, navigate to Quality Gates → Create/Edit:
Condition: Coverage on new code
Operator: is less than
Value: 80%This condition checks only lines/branches added or modified in the analyzed diff — not the entire file. Code that existed before is not penalized.
Additional useful conditions:
Coverage (overall): is less than 50% // Baseline floor
New Bugs: is greater than 0 // Zero new bugs policy
New Vulnerabilities: is greater than 0 // Zero new vulnerabilities
New Code Smells (BLOCKER): is greater than 0 // Block blockers onlyViewing Results in CI
After running the SonarQube scan and quality gate check:
- name: SonarQube Quality Gate check
uses: SonarSource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}Failed gate output:
Quality Gate FAILED
- Coverage on new code: 58.3% (required ≥ 80%)The SonarQube scan action also decorates the GitHub PR with coverage data per file.
Approach 2: Codecov
Codecov is a dedicated coverage platform that integrates deeply with GitHub. Its codecov.yml controls threshold enforcement.
Upload Configuration
# .github/workflows/test.yml
- name: Run tests
run: pytest --cov=src --cov-report=xml:coverage.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
flags: unittests
fail_ci_if_error: truecodecov.yml
# codecov.yml (repo root)
coverage:
# Overall coverage targets
status:
project:
default:
# Fail if overall coverage drops by more than 1%
threshold: 1%
target: 75% # Must be at or above 75%
# Patch coverage: only changed files
patch:
default:
# Fail if new lines are less than 70% covered
target: 70%
threshold: 0% # No tolerance — new code must meet the target
comment:
layout: "reach, diff, flags, files"
behavior: default
require_changes: false # Post comment even when coverage doesn't change
ignore:
- "tests/**"
- "migrations/**"
- "**/__init__.py"
- "conftest.py"Understanding Patch vs. Project Coverage
Project coverage: overall coverage of the entire codebase. Hard to move quickly on legacy codebases.
Patch coverage: coverage of lines added or modified in the PR. This is what you can enforce strictly — if a developer adds 50 new lines, those lines should have tests.
Enforce patch coverage strictly (70-85%) while giving the project target more room (50-65%) on older codebases.
Coverage Flags for Multi-Service Repos
# codecov.yml
flags:
unit:
paths:
- src/
after_n_builds: 1
integration:
paths:
- src/
after_n_builds: 1
coverage:
status:
project:
unit:
target: 80%
flags:
- unit
integration:
target: 60% # Integration tests cover less per line
flags:
- integrationUpload with flags:
- name: Upload unit coverage
uses: codecov/codecov-action@v4
with:
files: unit-coverage.xml
flags: unit
- name: Upload integration coverage
uses: codecov/codecov-action@v4
with:
files: integration-coverage.xml
flags: integrationSetting Realistic Thresholds
The Ratchet Strategy
Start where you are, then increase:
# Month 1: Start with current coverage (say 45%)
target: 45%
# Month 2: Raise by 5%
target: 50%
# Month 3
target: 55%
# Continue quarterly until you reach your target
target: 80%Automate the ratchet using a script that reads current coverage and writes the next target:
# scripts/update-coverage-target.py
import json
import yaml
# Read current coverage
with open('coverage/coverage-summary.json') as f:
summary = json.load(f)
current = summary['total']['lines']['pct']
# Read codecov.yml
with open('codecov.yml') as f:
config = yaml.safe_load(f)
# Ratchet up by 5%, cap at 85%
new_target = min(85.0, current + 5.0)
config['coverage']['status']['project']['default']['target'] = f"{new_target:.0f}%"
with open('codecov.yml', 'w') as f:
yaml.dump(config, f)
print(f"Coverage target updated: {current:.1f}% → {new_target:.0f}%")What to Exclude from Coverage
Not all code needs coverage. Exclude:
# codecov.yml
ignore:
- "migrations/**"
- "scripts/**"
- "**/__main__.py"
- "**/conftest.py"
- "tests/**" # Test files themselves
- "**/*.generated.*" # Generated code# sonar-project.properties
sonar.exclusions=**/migrations/**,**/generated/**
sonar.coverage.exclusions=**/config/**,**/scripts/**,**/cli/**Note the difference: sonar.exclusions excludes from all analysis; sonar.coverage.exclusions excludes only from coverage counting (still analyzed for bugs and smells).
PR Coverage Comments
Both tools add PR comments. Combined, this is what a PR might show:
Codecov comment:
## Coverage Report
| Files Changed | Coverage |
|---|---|
| src/auth/processor.py | 85% ✅ |
| src/payments/gateway.py | 62% ⚠️ |
Overall: 73.2% (↓ 0.8% from main)
Patch: 74.1% ✅ (target: 70%)SonarQube Quality Gate:
## Quality Gate: ❌ FAILED
- Coverage on new code: 62% (required ≥ 80%)
- src/payments/gateway.py: Only 12/30 new lines coveredThe combination gives developers specific feedback: which files lack coverage and which specific lines need tests.
Handling Coverage Exceptions
Sometimes untested code is acceptable: generated code, framework boilerplate, infrastructure scripts. Document exceptions explicitly rather than lowering thresholds:
# Code that's explicitly excluded from coverage requirements
# reason: AWS Lambda handler — tested via integration tests, not unit tests
def lambda_handler(event, context): # pragma: no cover
return process_event(event)/* istanbul ignore next */
function devOnlyHelper() {
// Only used in development, not in production code paths
}Track exclusions by searching for pragma: no cover and istanbul ignore in PRs — if these increase over time without corresponding integration test coverage, the quality gate is being gamed.
Summary
Coverage threshold enforcement works best with two tools in tandem:
- Codecov — enforces patch coverage (new code must be tested), shows per-file diffs in PRs
- SonarQube — enforces coverage as part of a broader quality gate (coverage + security + bugs)
Key principles:
- Enforce patch coverage strictly — new code must meet the threshold, regardless of legacy coverage
- Set project thresholds at current levels + a small margin — achievable today, increasing quarterly
- Exclude generated and framework code from coverage requirements
- Document coverage exceptions explicitly with
pragma: no coveroristanbul ignore
The ratchet strategy is the most practical path from low coverage to high coverage: start at current state, enforce that state strictly (no regressions), then increase the target 5% each quarter.