Istanbul / nyc: JavaScript Code Coverage Setup & Best Practices

Istanbul / nyc: JavaScript Code Coverage Setup & Best Practices

Istanbul is the most widely used JavaScript code coverage tool. Its CLI wrapper nyc makes it trivial to add coverage instrumentation to any Node.js project. This guide covers setup, configuration, coverage thresholds, CI integration, and how to actually use coverage data to improve your tests.

What Istanbul/nyc Measures

Istanbul tracks four coverage metrics:

  • Statements — each executable statement in your code
  • Branches — each conditional path (if/else, ternary, logical operators)
  • Functions — each function or method definition
  • Lines — each line containing executable code

A common misconception: 100% line coverage doesn't mean 100% branch coverage. A function with an if/else can have both lines executed but one branch never taken.

Installation

# Using nyc (the Istanbul CLI)
npm install --save-dev nyc

<span class="hljs-comment"># Or with mocha
npm install --save-dev mocha nyc

<span class="hljs-comment"># For Jest projects — Istanbul is built in, no extra package needed

Istanbul works natively with CommonJS. For ES modules and TypeScript, additional configuration is required (covered below).

Basic Setup

package.json Configuration

{
  "scripts": {
    "test": "mocha tests/**/*.test.js",
    "coverage": "nyc mocha tests/**/*.test.js",
    "coverage:report": "nyc report --reporter=html"
  },
  "nyc": {
    "include": ["src/**/*.js"],
    "exclude": ["tests/**", "node_modules/**"],
    "reporter": ["text", "lcov", "html"],
    "all": true
  }
}

Run coverage:

npm run coverage

Output:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |   87.50 |    75.00 |   83.33 |   87.50 |
 src/     |         |          |         |         |
  auth.js |   85.00 |    70.00 |   80.00 |   85.00 | 24-28, 67
  utils.js|   90.00 |    80.00 |   87.50 |   90.00 | 45
----------|---------|----------|---------|---------|-------------------

Configuration Options

.nycrc or .nycrc.json

Keep Istanbul config in a separate file for cleaner package.json:

{
  "include": ["src/**/*.js"],
  "exclude": [
    "**/*.test.js",
    "**/*.spec.js",
    "src/generated/**",
    "src/migrations/**"
  ],
  "extension": [".js", ".jsx"],
  "reporter": ["text", "lcov", "html", "json"],
  "all": true,
  "check-coverage": true,
  "branches": 80,
  "functions": 85,
  "lines": 85,
  "statements": 85,
  "temp-dir": "./.nyc_output"
}

The all: true flag is critical — without it, Istanbul only reports coverage for files that are actually imported during tests. Files with 0% coverage won't appear at all, creating a false sense of coverage completeness.

Coverage Thresholds

Setting check-coverage: true with threshold values causes nyc to exit with a non-zero code when coverage falls below the specified percentage. This integrates cleanly with CI:

{
  "check-coverage": true,
  "branches": 75,
  "functions": 80,
  "lines": 80,
  "statements": 80
}

If coverage drops below threshold:

ERROR: Coverage for branches (68.75%) does not meet global threshold (75%)
npm ERR! Test failed. See above for more details.

Starting from scratch:

  • New projects: 70%+ lines, 60%+ branches as a baseline
  • Mature projects: 80–85%+ lines, 75%+ branches
  • Critical path code: Consider 90%+ for auth, payment, or data validation

Don't chase 100%. Some code (error handling for truly exceptional cases, third-party adapter boilerplate) adds test complexity for minimal risk reduction.

TypeScript Support

Istanbul doesn't instrument TypeScript directly — you need source maps to map coverage back to .ts files:

npm install --save-dev ts-node @istanbuljs/schema source-map-support

.nycrc.json:

{
  "extension": [".ts", ".tsx"],
  "include": ["src/**/*.ts"],
  "exclude": ["**/*.test.ts", "**/*.spec.ts", "src/types/**"],
  "reporter": ["text", "lcov", "html"],
  "all": true,
  "require": ["ts-node/register", "source-map-support/register"]
}

Run with:

npx nyc mocha --require ts-node/register tests/**/*.test.ts

ES Module Support

ES modules require additional configuration since Istanbul historically targeted CommonJS:

npm install --save-dev c8

c8 is a newer coverage tool that uses Node.js's built-in V8 coverage — no instrumentation needed, works natively with ES modules:

{
  "scripts": {
    "coverage": "c8 --reporter=text --reporter=lcov node --experimental-vm-modules node_modules/.bin/mocha tests/**/*.test.mjs"
  }
}

For mixed CJS/ESM projects, c8 is generally easier than configuring Istanbul's experimental ESM support.

Reporters

Istanbul supports multiple output formats:

Reporter Output Use Case
text Terminal table Local development
text-summary One-line summary Quick CI check
lcov lcov.info file Codecov, Coveralls upload
html coverage/ folder Detailed local inspection
json coverage-final.json Programmatic consumption
clover Clover XML Jenkins coverage reports
cobertura Cobertura XML Azure DevOps, GitLab

For CI, combine text-summary (to see numbers in logs) and lcov (to upload to a coverage service):

{
  "reporter": ["text-summary", "lcov"]
}

CI/CD Integration

GitHub Actions

name: Tests with Coverage

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - run: npm ci
      
      - name: Run tests with coverage
        run: npm run coverage
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info
          fail_ci_if_error: true

GitLab CI with Coverage Badge

test:
  script:
    - npm ci
    - npx nyc --reporter=text --reporter=lcov mocha tests/**/*.test.js
    - npx nyc report --reporter=text-summary
  coverage: '/Lines\s*:\s*(\d+\.?\d*)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

Excluding Code from Coverage

Some code shouldn't be covered — configuration files, generated code, type definitions:

File-level exclusion

In .nycrc.json:

{
  "exclude": [
    "src/generated/**",
    "src/config/**",
    "**/__mocks__/**"
  ]
}

Inline exclusion with comments

/* istanbul ignore next */
function legacyFallback() {
  // Never runs in modern environments
}

// Single branch:
const value = condition
  ? doThis()
  : /* istanbul ignore next */ doThatNever();

// Entire block:
/* istanbul ignore if */
if (process.env.NODE_ENV === 'development') {
  enableDevTools();
}

Use inline ignores sparingly. Overuse is a code smell — it usually means the code is untestable rather than genuinely unreachable.

Reading Coverage Reports

The HTML report (run npx nyc report --reporter=html, open coverage/index.html) highlights uncovered lines in red. Common patterns to investigate:

Uncovered error branches:

async function fetchUser(id) {
  try {
    return await db.find(id);
  } catch (err) {      // ← this branch often uncovered
    throw new ApiError(500, 'DB error');
  }
}

Add a test that simulates a database failure.

Uncovered null guards:

function formatName(user) {
  if (!user) return 'Anonymous';  // ← uncovered if tests always pass a user
  return `${user.first} ${user.last}`;
}

Add a test with formatName(null).

Dead code:

function process(data) {
  // ... main logic ...
  return result;
  
  // This line never runs — actual dead code
  cleanup(data);
}

Delete it.

Common Pitfalls

all: false giving misleading numbers. Without all: true, files not imported during tests don't show up. Your 90% coverage might be 40% if half the codebase was never even loaded.

Counting mocks as coverage. Test doubles and mock files in src/ can inflate coverage artificially. Exclude __mocks__/ directories.

Treating coverage as quality. A test that calls a function but doesn't assert anything will contribute to coverage but not to quality. Coverage measures what code runs, not whether it's tested correctly.

Confusing statement and branch coverage. if (a && b) has one statement but three branches: a=false, a=true b=false, a=true b=true. Statement coverage misses this; branch coverage catches it.

Beyond Unit Test Coverage

Istanbul/nyc measures coverage from your unit tests. For end-to-end coverage (what code runs during browser-based tests), you need a different approach.

HelpMeTest runs end-to-end tests against your live application and tracks which user journeys are covered. Where Istanbul tells you which lines execute in unit tests, HelpMeTest tells you whether your actual user flows work in production. Together they give a complete picture — code-level coverage and real-world behavior validation.

Summary

Istanbul/nyc setup in three steps:

  1. npm install --save-dev nyc
  2. Add .nycrc.json with include, exclude, reporter, all: true, and thresholds
  3. Replace npm test with nyc npm test in CI

Key practices:

  • Always set all: true to surface files with 0% coverage
  • Use branch coverage thresholds, not just line coverage
  • Set coverage as a CI gate to prevent regression
  • Use lcov reporter to upload to Codecov or Coveralls
  • Read the HTML report to understand what's actually uncovered, not just the numbers

Read more