Coverage & CI Integration
A passing test suite only tells you the assertions you wrote succeeded — it says nothing about the code you forgot to test. Coverage reporting closes that gap by measuring which lines, branches, and functions your tests actually exercise, and a continuous integration (CI) pipeline runs that suite automatically on every push so broken code never reaches main. This page shows how to generate coverage with Jest’s built-in Istanbul instrumentation, enforce minimum thresholds, run the suite in GitHub Actions across multiple Node versions, and gate pull-request merges on green checks.
Generating coverage with Jest
Jest ships with Istanbul coverage built in, so no extra dependency is required. Pass --coverage to instrument your source files and print a per-file summary after the run.
npx jest --coverage
Output:
PASS src/services/price.test.js
PASS src/controllers/userController.test.js
-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 92.30 | 85.71 | 88.88 | 92.30 |
controllers | 95.00 | 90.00 | 100.00 | 95.00 |
userController.js | 95.00 | 90.00 | 100.00 | 95.00 | 24
services | 88.00 | 80.00 | 75.00 | 88.00 |
price.js | 88.00 | 80.00 | 75.00 | 88.00 | 12,19
-------------------|---------|----------|---------|---------|-------------------
The four metrics measure different things: statements and lines track executable code, functions tracks whether each function ran at all, and branches tracks each side of every if, ternary, and &&. Branch coverage is the strictest and most revealing — high line coverage with low branch coverage usually means error paths are untested.
Configuring coverage in package.json
Rather than remembering flags, configure coverage in a jest block (or jest.config.js). Control which files are measured, which report formats are emitted, and where output lands.
{
"scripts": {
"test": "jest",
"test:ci": "jest --coverage --ci --runInBand"
},
"jest": {
"collectCoverage": true,
"collectCoverageFrom": [
"src/**/*.js",
"!src/**/*.test.js",
"!src/server.js"
],
"coverageDirectory": "coverage",
"coverageReporters": ["text", "lcov", "html", "json-summary"]
}
}
collectCoverageFrom forces Jest to report on files that were never imported by any test — without it, an entirely untested module silently shows 0% by being absent. The lcov reporter produces coverage/lcov.info for upload to services like Codecov, while html writes a browsable coverage/lcov-report/index.html.
| Reporter | Produces | Use for |
|---|---|---|
text | Console table | Quick local feedback |
text-summary | One-line totals | CI logs |
lcov | lcov.info + HTML | Codecov / Coveralls upload |
html | Browsable report | Drilling into uncovered lines |
json-summary | coverage-summary.json | Custom badge/threshold scripts |
Add
coverage/to your.gitignore. Coverage artifacts are generated output and should never be committed — regenerate them in CI instead.
Enforcing coverage thresholds
A threshold turns coverage from a vanity number into an enforced gate: if any metric drops below the configured percentage, Jest exits with a non-zero code and fails the build. Set them under coverageThreshold.
{
"jest": {
"coverageThreshold": {
"global": {
"statements": 90,
"branches": 80,
"functions": 90,
"lines": 90
},
"./src/controllers/": {
"branches": 95
}
}
}
}
The global key applies to the whole codebase; path keys raise the bar for critical directories. When a threshold is missed, Jest reports exactly which metric fell short.
Output:
Jest: "global" coverage threshold for branches (80%) not met: 76.42%
Start thresholds slightly below your current numbers, then ratchet them upward as coverage improves. Setting them above today’s reality just leaves the build red and trains the team to ignore failures.
Running tests in GitHub Actions
GitHub Actions runs your suite on every push and pull request. Create .github/workflows/ci.yml. The workflow checks out the code, installs dependencies reproducibly with npm ci, and runs the coverage-enabled script across a matrix of Node versions.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Set up Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:ci
- name: Upload coverage to Codecov
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
npm ci installs the exact versions locked in package-lock.json, making CI deterministic. The --runInBand flag in test:ci runs suites serially, which is more stable on the limited CPUs of CI runners, and --ci makes Jest fail rather than write new snapshots. Coverage uploads only once (on Node 20) to avoid duplicate reports from the matrix.
Gating merges on passing checks
Running tests is only half the value — you also have to block merges when they fail. In the repository’s Settings → Branches, add a branch protection rule for main and enable Require status checks to pass before merging, then select the test jobs produced by the workflow above. With the rule active, a pull request whose suite fails or whose coverage falls below threshold cannot be merged, even by an administrator if “Do not allow bypassing” is checked.
Some checks were not successful
× CI / test (18) Failing after 41s
× CI / test (20) Failing after 39s
Merging is blocked
Because the coverage threshold makes Jest exit non-zero, a coverage regression surfaces as a failed check on the exact same gate — no separate tooling needed.
Best Practices
- Always set
collectCoverageFromso untested files count against your coverage instead of disappearing from the report. - Enforce
coverageThresholdin CI and ratchet it up over time rather than setting an aspirational number that stays red. - Watch branch coverage closely — it exposes untested error and edge-case paths that line coverage hides.
- Use
npm ci(notnpm install) in pipelines for reproducible, lockfile-exact installs. - Run the suite across a Node version matrix matching the runtimes you actually deploy to.
- Make the CI run a required status check so failing tests or coverage drops physically block merges into
main. - Treat 100% coverage as a smell, not a goal — chasing the last few percent often means testing trivial code instead of meaningful behaviour.