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: trueGitHub 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: trueThen require the Codecov check in branch protection:
Settings → Branches → Branch protection rules → main → Require status checks:
codecov/patchcodecov/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%"
fiIn GitHub Actions:
- name: Run tests
run: npm test -- --coverage
- name: Check coverage threshold
run: bash scripts/coverage-gate.sh
env:
COVERAGE_THRESHOLD: 80Approach 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.mddiff-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.shAdd 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.