Code Coverage and CI Integration
Code coverage tells you what percentage of your code runs during tests. High coverage does not guarantee bug-free code, but low coverage often means untested paths that will break in production. In this tutorial, you’ll learn how to measure code coverage, configure thresholds, and integrate testing into CI pipelines.
Note: This tutorial builds on concepts covered in End-to-End Testing with Playwright. Make sure you’ve completed that tutorial first if you’re new to testing.
Before you start
You should have Jest or Vitest installed and a basic test suite running. This tutorial covers coverage measurement, report interpretation, threshold enforcement, CI integration, and badge display — the full pipeline from local coverage to team enforcement.
Understanding code coverage
Coverage comes in several flavors:
- Line coverage: What percentage of lines executed?
- Branch coverage: What percentage of branches (if/else, ternary) taken?
- Function coverage: What percentage of functions called?
- Statement coverage: What percentage of statements executed?
Jest and Vitest use Istanbul (now Native V8 coverage) by default, providing all these metrics plus more granular information.
Setting up coverage
With Jest
Jest has coverage built in. Run tests with the --coverage flag:
npm test -- --coverage
The -- passes the flag through npm to Jest. For a permanent setup, configure Jest in your package.json or a dedicated config file so coverage runs on every test invocation without needing the CLI flag each time. This keeps the command short in CI scripts:
{
"jest": {
"collectCoverage": true,
"coverageDirectory": "coverage",
"coverageReporters": ["html", "text", "lcov"]
}
}
With Vitest
Vitest uses V8 coverage by default, which is faster than Istanbul for large projects. The CLI flag is the quickest way to try it out on a one-off basis, but for regular use you’ll want a config file that standardizes the coverage output across the team. A dedicated vitest.config.js also makes it easier to share settings across the project:
npx vitest run --coverage
Configure in vitest.config.js:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['html', 'text', 'lcov'],
reportsDirectory: './coverage',
},
},
});
Interpreting coverage reports
The Vitest config sets the coverage provider, output format, and report directory in one place. Both Jest and Vitest produce compatible reports, so the same CI pipeline can consume coverage from either tool without changes.
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, subtract, multiply };
The module exports three functions. The test file below only calls add and subtract, leaving multiply untouched. When coverage runs, the report will flag line 7 as uncovered — a direct signal that you need a test for the missing function:
// math.test.js
const { add, subtract, multiply } = require('./math');
test('adds numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('subtracts numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
You now have the source and the tests. Running Jest or Vitest with the --coverage flag generates a text summary that lists each file, the percentage of statements, branches, functions, and lines covered, plus the specific line numbers that were never reached during the test run:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
math.js | 75 | 100 | 66 | 75 | 7
----------|---------|----------|---------|---------|-------------------
The multiply function is not tested (line 7), dragging coverage down.
Adding threshold enforcement
Prevent coverage from regressing by setting minimum thresholds:
Jest
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/critical/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
};
If coverage drops below thresholds, Jest exits with failure.
Vitest
// vitest.config.js
export default defineConfig({
test: {
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
Coverage with TypeScript
Both tools handle TypeScript with proper source mapping, but the setup differs. Jest needs ts-jest as a preset and explicit source collection patterns so it knows which files to instrument. Vitest handles TypeScript natively through Vite, so no extra transformer is needed. For Jest with TypeScript, install the adapter and configure the preset:
# Jest with TypeScript
npm install --save-dev @types/jest ts-jest
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
collectCoverageFrom: [
'src/**/*.{js,ts}',
'!src/**/*.d.ts',
'!src/**/*.test.{js,ts}',
],
};
Ignoring Uncoverage
Sometimes code cannot be covered in tests, and that’s okay. Use pragmas to ignore:
// Using istanbul ignore comments
function /* istanbul ignore next */ legacyCode() {
// This will never run in tests
if (typeof window === 'undefined') {
return 'server';
}
return 'browser';
}
// Ignore specific branches
if (process.env.NODE_ENV === 'development') {
/* istanbul ignore else */
enableDebugLogging();
}
Istanbul’s ignore comments need to be placed on the line before the code you want to exclude. For Vitest projects that use V8 coverage, the syntax is slightly different — you write c8 ignore instead of istanbul ignore. The placement rules are the same, so the mental model transfers directly:
function legacyCode() {
/* c8 ignore next */
return 'unreachable';
}
Use sparingly; frequent ignores may indicate design problems.
Generating HTML Reports
HTML reports let you click through and see exactly which lines are uncovered. Configure your test runner to output HTML alongside the text summary so you can explore coverage visually during development:
// jest.config.js
module.exports = {
coverageReporters: ['html', 'text-summary'],
coverageDirectory: 'coverage',
};
Open coverage/index.html in a browser to explore.
For Vitest with V8:
Making coverage part of team habits
Coverage is most useful when it becomes part of a normal workflow instead of a one-time report. If a team checks coverage only when a release is already in trouble, the numbers quickly turn into decoration. A healthier approach is to treat coverage thresholds as a safety net that catches regressions before they spread. Even a simple minimum for lines and branches can stop a new change from quietly bypassing a tested path.
It also helps to read coverage reports with context. A file that has high line coverage may still have weak branch coverage if one side of a condition is never exercised. That means the report is not just a scorecard. It is a map of where the tests are thin and where a new edge case could surprise you later. When you review the output, look for the paths that matter most to users, not just the files with the lowest percentage.
In CI, keep the setup predictable. The same command should run locally and in the pipeline whenever possible, and the coverage output should be easy to find when a job fails. That makes the report part of the development loop rather than a mystery buried in build logs. If the team can see the numbers quickly, they are more likely to act on them early and less likely to ignore them until the end of the sprint.
For larger codebases, thresholds work best when they are tied to risk. Core payment logic, authentication code, and data migration paths often deserve stricter coverage than a rarely used helper. That does not mean the rest of the project should be neglected. It means the places that would hurt most if they broke need stronger guardrails. A thoughtful threshold policy gives you better protection than a single global number applied everywhere.
Finally, use the report to guide test design, not to create busywork. If coverage is low because a function is hard to exercise, the real issue may be that the function is doing too much. In that case, refactoring the code can improve both readability and testability at once. Good coverage is usually a side effect of good structure, so treat the report as feedback on design, not just as a pass or fail number.
export default defineConfig({
test: {
coverage: {
reporter: ['html', 'text-summary'],
reportsDirectory: './coverage',
},
},
});
CI Integration
GitHub Actions
# .github/workflows/test.yml
name: Test and Coverage
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
GitLab CI
GitLab CI uses a different YAML structure but the same principle — install dependencies, run the test command, and collect the coverage output. The coverage regex extracts the percentage from the test output for display in merge requests:
# .gitlab-ci.yml
test:
image: node:20
script:
- npm ci
- npm test
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
Netlify / Vercel
Most platforms support coverage reports as build artifacts. Add a CI-specific test command to your package.json so the platform always uses the same coverage flags:
// package.json
{
"scripts": {
"test:ci": "jest --coverage",
"test:ci": "vitest run --coverage"
}
}
Badge Integration
Show coverage status in your README. Badge services read your coverage data from the CI pipeline and display a live percentage that updates on every push. Both Codecov and Coveralls offer free tiers for public repositories:
Codecov
[](https://codecov.io/gh/username/repo)
Coveralls
[](https://coveralls.io/github/username/repo?branch=main)
Best Practices
-
Start with reasonable thresholds. 80% is a common starting point. Raise over time.
-
Focus on critical code first. Protect business logic and error handling.
-
Don’t chase 100%. Some code (error handlers, legacy branches) cannot reasonably be covered.
-
Review uncovered code. Click through the HTML report to see what’s missing.
-
Use coverage in PRs. Block merges that drop coverage below thresholds.
-
Track trends over time. Coverage should generally increase, not fluctuate wildly.
-
Combine with mutation testing. Coverage measures quantity; mutation testing measures quality.
Coverage vs. Quality
High coverage does not mean good tests. Consider this example:
function divide(a, b) {
if (b === 0) {
return Infinity;
}
return a / b;
}
// Test passes but misses the edge case
test('divides numbers', () => {
expect(divide(10, 2)).toBe(5);
});
This gives 50% branch coverage but misses a critical case. Use coverage as a guide, not a guarantee.
Summary
You now understand how to measure and enforce code coverage:
- Configure Jest or Vitest to collect coverage reports
- Set thresholds to prevent regression
- Interpret the various coverage types
- Use ignore comments sparingly
- Generate HTML reports for exploration
- Integrate coverage checks into CI pipelines
- Display coverage badges in your repository
Coverage is one tool in your testing toolbox. Combined with good test design, code review, and mutation testing, it helps ensure your JavaScript applications are reliable and maintainable.
Make coverage easy to read
Coverage numbers are only useful when the report tells you something concrete about the code. A good report highlights the files and branches that need attention without making the team hunt through noise. When you review the output, ask which paths are important to users and which ones are only present because a function has grown too large. That habit turns coverage into design feedback, not just a percentage.
It also helps to keep the report output close to the normal test command. If the same command works locally and in CI, developers can reproduce a failure without guessing which flags were used. A predictable workflow makes coverage part of the daily loop, which is where it can actually influence changes before they land.
Next steps
- Set up coverage in your own project starting with an 80% line-coverage threshold and raise it incrementally as the test suite grows
- Add a coverage badge to your README so the team sees the trend at a glance
- Explore Testing with Vitest if you want a faster alternative to Jest
See also
testing-e2e-playwright: end-to-end testing with Playwrighttesting-mocking: mocking modules and dependenciesprocess: access CI and environment variables