Coverage tells you how much of your code your tests actually run. Here's what that number really means in Node.js, and how to pick a threshold that catches real bugs without chasing 100%.

Sooner or later someone on the team asks for a number. "What's our test coverage?" And then the harder question: how much is enough? Chase 100% and you waste days testing getters and log lines. Ignore it and real bugs slip through untested branches. Test coverage in Node.js is a useful signal, but only if you understand what it actually counts and where it stops being meaningful.
The short version: aim for coverage on the code that would hurt if it broke, set a floor your CI enforces, and stop treating the percentage as the goal. The longer version is worth your time, because the wrong target quietly trains a team to write bad tests.
Line coverage is the percentage of executable lines that ran at least once during your tests. It's the number most people quote, and also the easiest to game.
Branch coverage is the percentage of decision paths that were taken — both the if and the else, both sides of a ternary, every case. This is the number that actually tells you something, because most bugs live in the branch you forgot to test.
The coverage tool instruments your code, runs the tests, and records which lines and branches executed. Node.js ships one in its built-in test runner via ; the standalone tool wraps the same V8 coverage data and produces richer reports.
--experimental-test-coveragec8Coverage measures which code executed, not whether you checked the result. That gap is everything.
javascript// math.test.jsimport test from "node:test";import { discount } from "./math.js";test("applies a discount", () => {discount(100, 0.2);});
This test calls discount, so the function shows as 100% covered. It asserts nothing. It would pass if discount returned a string, threw away the input, or formatted your hard drive. A coverage report rewards it exactly the same as a test with five real assertions. High coverage with weak assertions is more dangerous than low coverage, because it looks like safety.
The second trap is chasing the last few percent. Going from 0 to 70% covers the code that runs on every request. Going from 95 to 100% usually means testing defensive branches that can't happen, error handlers for impossible states, and trivial accessors. The effort curve goes vertical while the value goes flat. This is the same diminishing-returns logic behind the testing discipline in the five pillars of modern software delivery: tests exist to let you ship faster with confidence, not to satisfy a dashboard.
You don't need a dependency. The built-in runner reports coverage directly.
bash# package.json script: "coverage"node --test --experimental-test-coverage
That prints a per-file table of line, branch, and function percentages with the uncovered line numbers. For a nicer HTML report or finer control, c8 reads the same V8 data.
bash# package.json script: "coverage:html"c8 --reporter=html --reporter=text node --test
Read the report by branches, not lines. A file at 95% lines but 60% branches is hiding untested logic in its conditionals, and that's where you should spend the next hour.
A healthy target for most application code is roughly 80% lines and 70% branches, enforced as a floor rather than a goal. The point of the floor is to stop coverage from silently rotting as the codebase grows, not to force every file to the same number.
javascript// .c8rc.json{"check-coverage": true,"lines": 80,"branches": 70,"functions": 80}
With check-coverage, c8 exits non-zero when coverage drops below the floor, which turns it into a real gate.
bash# package.json script: "coverage:ci"c8 --check-coverage node --test

Wire that into CI and a pull request that drops coverage below the line fails before it merges. Pair it with a required status check and the rule enforces itself without anyone policing it in review.
Not all code deserves the same scrutiny. Push for high branch coverage on the parts where a mistake is expensive: pricing and money math, authorization checks, data validation, anything that writes to the database, and the error paths your users hit when something upstream fails. These are the branches worth testing twice.
Relax on the rest. Configuration loading, thin controllers that just forward to a service, generated code, and one-line accessors don't earn the maintenance cost of dedicated tests. If you exclude them, do it explicitly so the exclusion is visible and reviewed.
javascript// .c8rc.json (exclude section){"exclude": ["**/*.config.js", "**/migrations/**", "test/**"]}
A team that measures branch coverage on its core logic and enforces a sane floor in CI gets the real benefit of coverage: a fast warning when a risky path goes untested. Treat the number as a smoke detector, not a scoreboard, and it'll earn its place in your pipeline.
Stop dropping requests on deploy: handle SIGTERM, drain in-flight work, and close resources cleanly.

Node.js Performance Guide: Utilizing All CPU Cores with Cluster

A Practical Guide to CI/CD, TDD, and Trunk-Based Development