Enforcing Code Coverage in Pull Requests: Gates, Thresholds, and CI Checks

Enforcing Code Coverage in Pull Requests: Gates, Thresholds, and CI Checks

Coverage gates block pull request merges when new code isn't adequately tested. Set them up using Codecov, SonarQube, or custom scripts in GitHub Actions. The key is enforcing patch coverage (new code) rather than project coverage (overall), which prevents penalizing teams for pre-existing gaps.

Key Takeaways

Enforce patch coverage, not project coverage. New code must meet the threshold. Don't block PRs because of existing uncovered code.

Absolute thresholds cause gaming. If PRs need 80% coverage, developers write tests that hit 80% mechanically. Enforce meaningful coverage by requiring tests for new behavior.

Codecov and SonarQube both post GitHub status checks. If the check fails, GitHub prevents merging (if you configure the branch protection rule).

Allow bypassing for legitimate cases. Hot fixes, documentation PRs, and configuration changes shouldn't need full coverage gates.

Show coverage delta, not just total. Developers need to know if they improved or degraded coverage, not just the absolute number.

Why Coverage Gates Matter

Without coverage gates, coverage degrades over time. Developers prioritize shipping over testing, and the test suite drifts further from the code. Coverage gates make coverage regressions visible at the point where they're easiest to fix — before merging.

The right approach isn't requiring 90% overall coverage. It's requiring that new code is tested before it merges.

Approach 1: Codecov Status Checks

The simplest approach — Codecov posts status checks automatically after you configure codecov.yml:

# codecov.yml
coverage:
  status:
    patch:
      default:
        target: 80%           # new code must be 80% covered
        threshold: 0%
        only_pulls: true

GitHub Actions workflow to upload:

- name: Run tests with coverage
  run: npm test -- --coverage

- name: Upload to Codecov
  uses: codecov/codecov-action@v4
  with:
    token: ${{ secrets.CODECOV_TOKEN }}
    files: coverage/lcov.info
    fail_ci_if_error: true

Then require the Codecov check in branch protection:

Settings → Branches → Branch protection rules → main → Require status checks:

  • codecov/patch
  • codecov/project

Now PRs are blocked until Codecov reports the coverage check passes.

Approach 2: SonarQube Quality Gates

# GitHub Actions
- name: SonarQube Scan
  uses: SonarSource/sonarqube-scan-action@master
  env:
    SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
    SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
  with:
    args: >
      -Dsonar.qualitygate.wait=true
      -Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
      -Dsonar.pullrequest.branch=${{ github.head_ref }}
      -Dsonar.pullrequest.base=${{ github.base_ref }}

sonar.qualitygate.wait=true makes the GitHub Actions step wait for the quality gate result and fail if it fails. SonarQube automatically posts a status check to GitHub.

Quality gate conditions (Administration → Quality Gates → Your Gate):

  • Coverage on new code ≥ 80%
  • New violations = 0

Approach 3: Custom Coverage Gate Script

For full control without external services:

#!/bin/bash
# coverage-gate.sh

LCOV_FILE="coverage/lcov.info"
THRESHOLD=${COVERAGE_THRESHOLD:-80}

# Calculate coverage percentage from lcov
TOTAL_LINES=$(grep -c "^DA:" $LCOV_FILE || echo 0)
COVERED_LINES=$(grep "^DA:[0-9]*,[^0]" $LCOV_FILE | wc -l || echo 0)

if [ "$TOTAL_LINES" -eq 0 ]; then
  echo "No coverage data found"
  exit 1
fi

COVERAGE=$((COVERED_LINES * 100 / TOTAL_LINES))

echo "Coverage: $COVERAGE% ($COVERED_LINES/$TOTAL_LINES lines)"

if [ "$COVERAGE" -lt "$THRESHOLD" ]; then
  echo "FAIL: Coverage $COVERAGE% is below threshold $THRESHOLD%"
  exit 1
else
  echo "PASS: Coverage $COVERAGE% meets threshold $THRESHOLD%"
fi

In GitHub Actions:

- name: Run tests
  run: npm test -- --coverage

- name: Check coverage threshold
  run: bash scripts/coverage-gate.sh
  env:
    COVERAGE_THRESHOLD: 80

Approach 4: diff-coverage (Patch-Only Coverage)

diff-coverage calculates coverage only on lines changed in the current PR — the purest form of patch coverage:

pip install diff-cover
# GitHub Actions
- name: Get diff
  run: git diff origin/${{ github.base_ref }}...HEAD > git.diff

- name: Run tests with coverage
  run: pytest --cov=src --cov-report=xml:coverage.xml

- name: Check diff coverage
  run: |
    diff-cover coverage.xml \
      --diff-file=git.diff \
      --compare-branch=origin/${{ github.base_ref }} \
      --fail-under=80 \
      --markdown-report=coverage-report.md
      
- name: Post coverage report to PR
  uses: marocchino/sticky-pull-request-comment@v2
  with:
    path: coverage-report.md

diff-cover only checks coverage on lines in the diff. A line that was already untested before your PR doesn't count against you.

Handling Legitimate Coverage Drops

Some PRs legitimately can't meet coverage thresholds:

Skip Coverage for Specific PRs

# GitHub Actions
- name: Check if coverage gate should apply
  id: coverage-check
  run: |
    LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
    if echo "$LABELS" | grep -q "skip-coverage"; then
      echo "skip=true" >> $GITHUB_OUTPUT
    else
      echo "skip=false" >> $GITHUB_OUTPUT
    fi

- name: Run coverage gate
  if: steps.coverage-check.outputs.skip != 'true'
  run: bash scripts/coverage-gate.sh

Add a skip-coverage label for hot fixes, documentation PRs, and config-only changes.

Per-Path Thresholds

Lower thresholds for specific directories:

# codecov.yml
coverage:
  status:
    project:
      scripts:
        paths:
          - scripts/**
        target: 50%   # scripts are harder to test

      src:
        paths:
          - src/**
        target: 85%

Coverage-Exempt File Patterns

# codecov.yml
ignore:
  - "scripts/**"
  - "src/migrations/**"
  - "src/seeds/**"

Showing Coverage Delta in PR Comments

Developers need to know how their PR affected coverage, not just the absolute number:

Codecov shows this automatically in PR comments:

Coverage report

- Coverage decreased (-2.43%) to 77.48% when pulling feature/new-endpoint
  
Files Changed:
| File | Coverage | +/- |
|---|---|---|
| src/api/endpoint.ts | 60.0% | -20.0% |
| src/utils/helper.ts | 100.0% | +40.0% |

Custom comment with lcov-parser:

BASE_COVERAGE=$(git stash && npm test -- --coverage 2>/dev/null | grep "Statements" | awk '{print $3}' | tr -d '%')
git stash pop
CURRENT_COVERAGE=$(npm test -- --coverage 2>/dev/null | grep "Statements" | awk '{print $3}' | tr -d '%')
DELTA=$(echo "$CURRENT_COVERAGE - $BASE_COVERAGE" | bc)
echo "Coverage delta: ${DELTA}% (base: ${BASE_COVERAGE}%, PR: ${CURRENT_COVERAGE}%)"

Summary: Choosing Your Approach

Approach Best for Effort
Codecov status checks Teams using Codecov already Low
SonarQube quality gates Teams using SonarQube Medium
diff-cover Python/Ruby projects, strict patch coverage Medium
Custom script Full control, any language Medium
GitHub Actions built-in No external service Medium

The most important decision: enforce patch coverage (new code), not project coverage (overall). Overall coverage enforcement punishes teams for past decisions and encourages meaningless test inflation. Patch coverage enforces the right habit: test what you write.

Read more

Start now free