nyc
nyc is the command line front end for Istanbul, the code coverage tool most of the JavaScript world standardised on. You put nyc in front of whatever command runs your tests, as in nyc mocha, and it hooks Node's require so that every source file your tests load gets rewritten on the fly with counters wired into each line, branch, and function. When the process exits, nyc reads the raw counts your tests left in .nyc_output and renders them as a terminal table, an HTML report, an lcov file for Codecov or Coveralls, or any combination. It also follows subprocesses, so a test that spawns a CLI still gets counted, and it reads source maps so coverage on TypeScript or Babel output maps back to the lines you actually wrote. The catch is the mechanism: instrumentation happens through the CommonJS require hook, which is why nyc predates native ES modules and has never fully caught up with them.
nyc is a well-understood tool in maintenance mode whose core mechanism, the CommonJS require hook, is the wrong shape for how JavaScript is written now. Keep it on an existing CommonJS suite that depends on subprocess coverage or Istanbul ignore hints; for anything new, start with c8 or whatever coverage your runner already includes.
Use it if
- Your test suite is CommonJS and your runner has no coverage of its own: mocha, tape, AVA, or a plain node script are all cases nyc was built for
- Your tests spawn child processes, such as a CLI integration suite, and you need coverage from those subprocesses merged into the same report; nyc does this through spawn-wrap and most alternatives do not
- You need to combine several separate runs into one number, for example unit tests and integration tests invoked by different scripts, using nyc --silent, --no-clean, and a final nyc report
- You want Istanbul's exact ignore hints, such as /* istanbul ignore next */ and the ignore-class-method config, which V8-based tools do not implement
- You are on an existing project with a tuned .nycrc, shared @istanbuljs presets, and CI thresholds, and replacing all of it would cost more than the tool does
- Your source is native ES modules. nyc instruments through the CommonJS require hook, so import statements bypass it and you get empty or partial reports. The only ESM handling in the changelog is an experimental --all flag from June 2020. c8 uses V8's built-in coverage and just works here
- Your runner already ships coverage. The README says it plainly: with jest or tap you do not need nyc. Vitest has @vitest/coverage-v8, and Node 22 and later has node --test --experimental-test-coverage built in
- You care about install weight or install time. nyc pulls in 27 direct dependencies, over 100 packages once the tree resolves, including yargs, glob, spawn-wrap, and the whole istanbul-lib family. c8 does the same job with a fraction of that
- You want a tool that is still being developed. 18.0.0 in February 2026 and 17.0.0 in 2024 were both dependency and Node-floor bumps; the last release with a feature in it was 15.1.0 in June 2020, and there are 188 open issues plus 19 open pull requests
- Instrumentation cost matters to you. Rewriting every source file before it runs makes a slow suite slower and shows up in flaky timing-sensitive tests, whereas V8 coverage adds close to nothing
- You are on Windows with subprocess-heavy tests. spawn-wrap works by injecting a wrapper around the spawned command, and that layer is the source of a long tail of platform-specific failures
Setup reality
npm i -D nyc and prefixing your test script gets you a report, and then the defaults start arguing with you. By default nyc only counts files that were require()d during a test, so a file nobody imports is simply absent from the report rather than showing 0 percent, and fixing that means setting all: true, which then instruments things you never wanted. The default exclude list lives in @istanbuljs/schema rather than in your project, and the moment you write your own exclude array you replace those defaults entirely instead of adding to them, which is how test files start appearing in coverage numbers. Include and exclude globs are matched with minimatch against paths relative to the project root, the root being the nearest directory above cwd with a package.json, and any glob you pass on the command line has to be quoted or your shell expands it first. Configuration can live in seven places: .nycrc, .nycrc.json, .nycrc.yaml, .nycrc.yml, nyc.config.js, nyc.config.cjs, nyc.config.mjs, or an nyc stanza in package.json, and the full flag list is only in nyc --help rather than the README. TypeScript and Babel each need a preset, @istanbuljs/nyc-config-typescript or @istanbuljs/nyc-config-babel, pulled in through the extends key, plus a loader such as ts-node registered with --require, and with Babel the --require @babel/register has to go on nyc itself and not on the test runner or --all sees nothing. Three directories appear in your project and all three want gitignoring: .nyc_output for raw counts, coverage for reports, and node_modules/.cache/nyc for instrumented sources, and a stale cache after a toolchain change produces coverage numbers that make no sense until you delete it. Version 18 requires Node 20 or 22 and later.
Patterns
Add coverage to an existing test scriptbasic-usage
npm i -D nyc
// package.json
{
"scripts": {
"test": "mocha",
"coverage": "nyc npm run test"
}
}Wrapping an npm script works as long as that script does not already invoke nyc, otherwise you get nested instrumentation and wrong numbers. Flags always go before the command being run: nyc --reporter=lcov mocha, never mocha with nyc flags after it.
Move settings out of the command lineconfig-file
// .nycrc
{
"all": true,
"include": ["src/**/*.js"],
"exclude": ["**/*.spec.js", "**/fixtures/**"],
"reporter": ["text-summary", "lcov"],
"report-dir": "./coverage",
"skip-full": true
}Seven file names are accepted, including .nycrc.yaml and nyc.config.js, plus an nyc key in package.json. Writing your own exclude array replaces the defaults from @istanbuljs/schema rather than extending them, so re-add anything you still want excluded such as test directories.
Stop untested files from vanishing from the reportcover-untested-files
nyc --all --include 'src/**/*.js' npm testWithout --all, nyc only reports on files that were actually require()d, so a module with no tests at all is missing from the report instead of showing 0 percent, and your coverage number looks better than it is. Turning it on usually means tightening include at the same time.
Fail CI when coverage dropsenforce-thresholds
// .nycrc
{
"check-coverage": true,
"branches": 80,
"lines": 90,
"functions": 85,
"statements": 90,
"per-file": false
}check-coverage must be true or the thresholds are decoration. per-file switches from an aggregate check to failing on any single file below the bar, which on a large codebase turns one red build into dozens. You can also run nyc check-coverage as a separate step against an existing .nyc_output.
Get source-mapped coverage on TypeScripttypescript-setup
npm i -D @istanbuljs/nyc-config-typescript source-map-support ts-node
// .nycrc
{
"extends": "@istanbuljs/nyc-config-typescript",
"all": true,
"check-coverage": true
}
# then
nyc mocha -r ts-node/register 'test/**/*.spec.ts'The preset sets the extensions, source map handling, and exclusions you would otherwise get wrong by hand. It only helps if the TypeScript is compiled through a require hook such as ts-node; if you run compiled ESM output directly, nyc's hook never fires and you get an empty report.
Emit different reports for humans and for CIchoose-reporters
nyc --reporter=text-summary --reporter=lcov --reporter=json mocha
# reports land in ./coverage
# coverage/lcov.info -> Codecov, Coveralls, SonarQube
# coverage/index.html -> browsable HTML
# coverage/coverage-final.json--reporter is repeatable and the default is just text. lcov writes both lcov.info and the HTML report. Any npm package whose name matches a reporter can be used, so a custom reporter is a devDependency plus its name in the array.
One coverage number from several test commandscombine-multiple-runs
{
"scripts": {
"cover": "npm run cover:unit && npm run cover:integration && npm run cover:report",
"cover:unit": "nyc --silent npm run test:unit",
"cover:integration": "nyc --silent --no-clean npm run test:integration",
"cover:report": "nyc report --reporter=lcov --reporter=text"
}
}--silent collects without reporting, and --no-clean stops the second run from wiping .nyc_output. Forget --no-clean and your final report silently covers only the last run. nyc merge is a different thing: it flattens .nyc_output into one raw JSON file for an external tool, it does not produce a report.
Exclude code you deliberately do not testignore-code
/* istanbul ignore file */ // whole file, must be at the top
/* istanbul ignore next */
function debugOnly () { /* ... */ }
/* istanbul ignore else */
if (typeof window === 'undefined') { /* ... */ }
// .nycrc
{ "ignore-class-method": ["render"] }These are Istanbul's own comments and V8-based tools such as c8 do not honour them, so they are one of the real reasons to stay on nyc. ignore next skips the entire next construct, which on a large class is more than people expect.
Count code that runs in a spawned processsubprocess-coverage
nyc --require ts-node/register mocha test/cli.spec.js
# child processes are wrapped automatically;
# to see what is happening when they are not:
nyc --show-process-tree mochaThis is nyc's strongest remaining advantage: spawn-wrap injects the instrumentation into child processes so a CLI integration test still reports coverage. It also causes the most platform-specific breakage, particularly on Windows and with processes that replace argv or re-exec themselves.
What to do when the report comes back emptyesm-limitation
# nyc with native ESM: instrumentation never runs
nyc mocha test/*.mjs # -> 0% or missing files
# c8 reads V8 coverage instead, same reporters
npm i -D c8
c8 --reporter=lcov --reporter=text mocha test/*.mjsAn empty or wildly low report on an ESM project is not a config mistake, it is the require hook not being involved at all. c8 accepts most of the same flags and writes the same Istanbul reports, so the migration is usually one word in a package.json script.
Know which directories nyc createscache-and-artifacts
# .gitignore
.nyc_output/
coverage/
# clear a stale instrumentation cache
nyc --cache false npm test
rm -rf node_modules/.cache/nycInstrumented sources are cached in node_modules/.cache/nyc, and after changing a Babel or TypeScript config that cache can keep serving code instrumented under the old settings, which produces numbers that do not match the source. Deleting it is the first thing to try when coverage looks impossible.
Report on code you instrumented at build timepre-instrumented-code
nyc instrument src/ instrumented/
# then, when reporting:
nyc --exclude-after-remap false report --reporter=lcovWith babel-plugin-istanbul or nyc instrument doing the work up front, nyc's own hook is not involved. Leaving exclude-after-remap at its default drops every file whose source map points back into an excluded folder, which is why pre-instrumented setups often report on nothing at all.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| c8 | npm | You want the same Istanbul reports without instrumentation: it reads V8's native coverage, so ES modules, TypeScript, and workers all work with far fewer dependencies |
| @vitest/coverage-v8 | npm | Your tests already run on Vitest and you want coverage configured in the same file as everything else |
| babel-plugin-istanbul | npm | You need Istanbul-quality instrumentation applied at build time, typically for browser or bundled code where a Node require hook never runs |
| jest | npm | You are choosing a runner rather than a coverage tool, since jest --coverage uses the same Istanbul reporters with nothing extra to install |