Enforcing Test Coverage Thresholds with SonarQube and Codecov

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:

  1. Write tests for new code before merging
  2. 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.xml

JavaScript (Jest):

jest --coverage --coverageReporters=lcov

Java (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=cobertura
sonar.cs.opencover.reportsPaths=**/TestResults/**/*.xml

Configuring 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 only

Viewing 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: true

codecov.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:
          - integration

Upload 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: integration

Setting 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 covered

The 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:

  1. Codecov — enforces patch coverage (new code must be tested), shows per-file diffs in PRs
  2. 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 cover or istanbul 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.

Read more