Skip to content
Express.js ex testing 5 min read

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.

ReporterProducesUse for
textConsole tableQuick local feedback
text-summaryOne-line totalsCI logs
lcovlcov.info + HTMLCodecov / Coveralls upload
htmlBrowsable reportDrilling into uncovered lines
json-summarycoverage-summary.jsonCustom 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 collectCoverageFrom so untested files count against your coverage instead of disappearing from the report.
  • Enforce coverageThreshold in 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 (not npm 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.
Last updated June 14, 2026
Was this helpful?