Code Coverage Analysis: Metrics That Matter
A practical guide to code coverage in Node.js including line, branch, function, and statement coverage with tools like Istanbul/nyc, c8, and strategies for meaningful coverage targets.
Code Coverage Analysis: Metrics That Matter
Code coverage tells you what your tests execute. It does not tell you what your tests verify. That distinction matters more than any coverage percentage.
I have seen teams with 95% coverage ship bugs that basic manual testing would catch. I have also seen teams with 60% coverage and excellent quality because they covered the right code. This guide focuses on measuring coverage effectively and using the numbers to improve your test suite rather than just inflate a metric.
Prerequisites
- Node.js installed (v16+)
- A project with existing tests (Jest, Mocha, or Node test runner)
- Basic understanding of testing concepts
Coverage Metrics Explained
Line Coverage
Measures whether each line of source code was executed during testing.
function processPayment(amount, method) {
if (amount <= 0) { // Line 1 — covered if tested
throw new Error("Invalid amount"); // Line 2 — only covered if amount <= 0 is tested
}
if (method === "credit") { // Line 3
return chargeCreditCard(amount); // Line 4 — only covered if method is "credit"
}
if (method === "debit") { // Line 5
return chargeDebitCard(amount); // Line 6
}
throw new Error("Unknown method"); // Line 7 — only covered if invalid method tested
}
If your test only calls processPayment(100, "credit"), lines 1, 3, and 4 are covered. Lines 2, 5, 6, and 7 are not. Line coverage: 3/7 = 43%.
Branch Coverage
Measures whether each branch of conditional logic was taken. This is the most valuable coverage metric.
function getDiscount(user, orderTotal) {
var discount = 0;
if (user.isPremium) { // Branch: true / false
discount = 0.10;
}
if (orderTotal > 100) { // Branch: true / false
discount = discount + 0.05;
}
return discount;
}
Four branches exist: isPremium true/false and orderTotal > 100 true/false. Testing only getDiscount({ isPremium: true }, 150) covers two branches (both true). You miss the cases where isPremium is false and orderTotal is 100 or less.
Function Coverage
Measures whether each function was called at least once.
// If only processPayment is tested, function coverage = 1/3 = 33%
function processPayment(amount) { /* ... */ } // Covered
function refundPayment(transactionId) { /* ... */ } // Not covered
function getTransactionHistory(userId) { /* ... */ } // Not covered
Statement Coverage
Similar to line coverage but counts individual statements. A single line can have multiple statements:
// One line, two statements
var x = 5; var y = 10;
// One line, conditional expression (ternary counts as branches)
var status = amount > 0 ? "credit" : "debit";
Setting Up Coverage Tools
Istanbul/nyc with Mocha
npm install --save-dev nyc mocha
{
"scripts": {
"test": "mocha 'src/**/*.test.js'",
"test:coverage": "nyc mocha 'src/**/*.test.js'"
},
"nyc": {
"reporter": ["text", "html", "lcov"],
"include": ["src/**/*.js"],
"exclude": ["src/**/*.test.js", "src/test/**"],
"branches": 80,
"lines": 80,
"functions": 80,
"statements": 80,
"check-coverage": true
}
}
Jest Built-in Coverage
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.js",
"!src/**/*.test.js",
"!src/test/**"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
c8 with Node Test Runner
npm install --save-dev c8
{
"scripts": {
"test": "node --test src/**/*.test.js",
"test:coverage": "c8 node --test src/**/*.test.js"
},
"c8": {
"reporter": ["text", "html", "lcov"],
"include": ["src/**/*.js"],
"exclude": ["src/**/*.test.js"],
"branches": 80,
"lines": 80,
"functions": 80,
"statements": 80,
"check-coverage": true
}
}
Why c8 over nyc for modern projects: c8 uses V8's built-in coverage instead of instrumenting code. This means faster execution and accurate coverage for native ESM modules. nyc instruments source code before running it, which can interfere with some language features.
Reading Coverage Reports
Terminal Output
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
----------|---------|----------|---------|---------|-------------------
All files | 78.43 | 65.22 | 82.35 | 79.07 |
auth.js | 91.67 | 83.33 | 100.00 | 91.67 | 24,37
db.js | 68.75 | 50.00 | 66.67 | 68.75 | 18-25,41-48
user.js | 75.00 | 60.00 | 80.00 | 76.92 | 15,28-32
----------|---------|----------|---------|---------|-------------------
Key information:
- % Branch at 65.22 is the most actionable number — many conditional paths are untested
- Uncovered Lines shows exactly where to add tests
- db.js lines 18-25, 41-48 — two blocks of untested code, likely error handling
HTML Report
Run open coverage/index.html (or start coverage/index.html on Windows) to see an interactive report. The HTML report highlights each line:
- Green — executed during tests
- Red — never executed
- Yellow — partially covered (branch not fully explored)
- Numbers on branches — how many times each branch was taken
Practical Coverage Analysis
Example: Finding Missing Test Cases
// paymentProcessor.js
function processRefund(order, reason) {
if (!order) {
throw new Error("Order is required");
}
if (order.status !== "completed") {
throw new Error("Can only refund completed orders");
}
if (order.refundedAt) {
throw new Error("Order already refunded");
}
var daysSinceOrder = Math.floor(
(Date.now() - order.completedAt.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceOrder > 30) {
return { success: false, reason: "Refund window expired" };
}
var refundAmount = order.total;
if (reason === "partial_damage") {
refundAmount = order.total * 0.5;
}
return {
success: true,
amount: refundAmount,
originalOrder: order.id
};
}
module.exports = { processRefund: processRefund };
The coverage report shows branches at 50%. Here is how to identify what is missing:
// paymentProcessor.test.js
var processor = require("./paymentProcessor");
describe("processRefund", function() {
test("refunds a completed order", function() {
var order = {
id: 1,
status: "completed",
total: 100,
completedAt: new Date(),
refundedAt: null
};
var result = processor.processRefund(order, "defective");
expect(result.success).toBe(true);
expect(result.amount).toBe(100);
});
// Coverage report says these branches are missed:
// - order is null/undefined
// - order.status !== "completed"
// - order.refundedAt is truthy
// - daysSinceOrder > 30
// - reason === "partial_damage"
test("throws when order is missing", function() {
expect(function() {
processor.processRefund(null, "defective");
}).toThrow("Order is required");
});
test("throws for non-completed orders", function() {
var order = { status: "pending" };
expect(function() {
processor.processRefund(order, "defective");
}).toThrow("Can only refund completed orders");
});
test("throws for already refunded orders", function() {
var order = { status: "completed", refundedAt: new Date() };
expect(function() {
processor.processRefund(order, "defective");
}).toThrow("Order already refunded");
});
test("rejects refunds after 30 days", function() {
var oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 31);
var order = {
id: 1,
status: "completed",
total: 100,
completedAt: oldDate,
refundedAt: null
};
var result = processor.processRefund(order, "defective");
expect(result.success).toBe(false);
expect(result.reason).toBe("Refund window expired");
});
test("applies 50% refund for partial damage", function() {
var order = {
id: 1,
status: "completed",
total: 100,
completedAt: new Date(),
refundedAt: null
};
var result = processor.processRefund(order, "partial_damage");
expect(result.success).toBe(true);
expect(result.amount).toBe(50);
});
});
After adding these tests, branch coverage goes from 50% to 100%.
Coverage Thresholds That Make Sense
Global Thresholds
{
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 85,
"lines": 85,
"statements": 85
}
}
}
Per-Directory Thresholds
Different parts of a codebase deserve different coverage levels:
{
"coverageThreshold": {
"global": {
"branches": 70,
"lines": 75
},
"./src/core/": {
"branches": 90,
"lines": 95
},
"./src/utils/": {
"branches": 95,
"lines": 95
},
"./src/routes/": {
"branches": 60,
"lines": 70
}
}
}
Rationale:
- Core business logic (90%+) — payment processing, authentication, data validation. Bugs here are expensive.
- Utility functions (95%+) — pure functions with clear inputs and outputs. Easy to test thoroughly.
- Routes/Controllers (60-70%) — thin layers that delegate to services. Integration tests cover these better.
- Configuration/Setup (no threshold) — app startup, database connections. Tested by integration tests, not unit tests.
What Coverage Cannot Tell You
Executed Does Not Mean Verified
// 100% line coverage, 0% useful testing
test("processPayment runs", function() {
processPayment(100, "credit");
// No assertions! The test covers every line
// but verifies nothing about correctness.
});
// Lower coverage, actually useful
test("processPayment charges correct amount", function() {
var result = processPayment(100, "credit");
expect(result.amount).toBe(100);
expect(result.method).toBe("credit");
expect(result.status).toBe("charged");
});
Coverage Misses Logic Errors
function calculateTax(amount, rate) {
return amount + rate; // BUG: should be amount * rate
}
test("calculates tax", function() {
// 100% coverage — but the test is wrong too
expect(calculateTax(100, 0.08)).toBe(100.08);
// Should be 8.00, not 100.08
});
Coverage Does Not Measure Edge Cases
function divide(a, b) {
return a / b;
}
test("divides numbers", function() {
expect(divide(10, 2)).toBe(5); // 100% coverage
// Missing: divide(10, 0) → Infinity
// Missing: divide(0, 0) → NaN
// Missing: divide(-10, 3) → negative result
});
Integrating Coverage in CI/CD
GitHub Actions
name: Tests
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 install
- run: npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Enforcing Coverage in Pre-Commit
{
"scripts": {
"precommit:coverage": "jest --coverage --changedSince=main"
}
}
This runs coverage only on files changed since main, making pre-commit hooks fast while still catching coverage regressions.
Coverage Diff on Pull Requests
# Compare coverage between branches
# Before (main branch)
npm run test:coverage
cp coverage/coverage-summary.json coverage-main.json
# After (feature branch)
git checkout feature-branch
npm run test:coverage
# Compare
node -e "
var main = require('./coverage-main.json');
var feature = require('./coverage/coverage-summary.json');
var diff = feature.total.branches.pct - main.total.branches.pct;
console.log('Branch coverage change: ' + diff.toFixed(2) + '%');
if (diff < -2) {
console.log('WARNING: Branch coverage dropped by more than 2%');
process.exit(1);
}
"
Complete Working Example
A module with full coverage analysis:
// validator.js
function validateEmail(email) {
if (typeof email !== "string") {
return { valid: false, error: "Email must be a string" };
}
email = email.trim();
if (email.length === 0) {
return { valid: false, error: "Email is required" };
}
if (email.length > 254) {
return { valid: false, error: "Email is too long" };
}
var atIndex = email.indexOf("@");
if (atIndex === -1) {
return { valid: false, error: "Email must contain @" };
}
var local = email.substring(0, atIndex);
var domain = email.substring(atIndex + 1);
if (local.length === 0) {
return { valid: false, error: "Local part is empty" };
}
if (domain.length === 0) {
return { valid: false, error: "Domain is empty" };
}
if (domain.indexOf(".") === -1) {
return { valid: false, error: "Domain must have a TLD" };
}
return { valid: true, normalized: email.toLowerCase() };
}
module.exports = { validateEmail: validateEmail };
// validator.test.js
var validator = require("./validator");
describe("validateEmail", function() {
// Valid emails
test("accepts a valid email", function() {
var result = validator.validateEmail("[email protected]");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("[email protected]");
});
test("normalizes to lowercase", function() {
var result = validator.validateEmail("[email protected]");
expect(result.normalized).toBe("[email protected]");
});
test("trims whitespace", function() {
var result = validator.validateEmail(" [email protected] ");
expect(result.valid).toBe(true);
});
// Invalid emails — each branch tested
test("rejects non-string input", function() {
expect(validator.validateEmail(123).error).toBe("Email must be a string");
expect(validator.validateEmail(null).error).toBe("Email must be a string");
});
test("rejects empty string", function() {
expect(validator.validateEmail("").error).toBe("Email is required");
expect(validator.validateEmail(" ").error).toBe("Email is required");
});
test("rejects overly long email", function() {
var long = "a".repeat(250) + "@example.com";
expect(validator.validateEmail(long).error).toBe("Email is too long");
});
test("rejects email without @", function() {
expect(validator.validateEmail("shaneexample.com").error).toBe("Email must contain @");
});
test("rejects empty local part", function() {
expect(validator.validateEmail("@example.com").error).toBe("Local part is empty");
});
test("rejects empty domain", function() {
expect(validator.validateEmail("shane@").error).toBe("Domain is empty");
});
test("rejects domain without TLD", function() {
expect(validator.validateEmail("shane@localhost").error).toBe("Domain must have a TLD");
});
});
Running coverage:
npx jest --coverage validator.test.js
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
----------|---------|----------|---------|---------|-------------------
validator | 100.00 | 100.00 | 100.00 | 100.00 |
----------|---------|----------|---------|---------|-------------------
This is meaningful 100% coverage because every branch has a test with an assertion that verifies the correct error message or return value.
Common Issues and Troubleshooting
Coverage numbers are lower than expected
Files outside the include pattern may be pulling down averages, or generated/vendored files are being counted:
Fix: Configure include and exclude patterns to cover only your source code. Exclude test files, configuration, and third-party code explicitly.
Coverage is high but bugs still ship
Tests execute code without meaningful assertions:
Fix: Search for tests without expect() or assert calls. Enable the jest/expect-expect ESLint rule to catch assertion-free tests. Focus on branch coverage over line coverage.
c8 shows different numbers than nyc
c8 uses V8's native coverage while nyc instruments code. V8 coverage is more accurate for modern JavaScript features:
Fix: c8 numbers are generally more accurate. If migrating from nyc, expect small differences. Use c8 for new projects.
Coverage report includes node_modules
The tool is scanning installed packages:
Fix: Add "exclude": ["node_modules/**"] to your coverage configuration. Most tools exclude node_modules by default, but verify your config.
Best Practices
- Focus on branch coverage over line coverage. Branch coverage reveals untested conditional paths. Line coverage can be 100% while missing half the branches.
- Set thresholds that prevent regression, not thresholds that feel impressive. Start with your current coverage and set the threshold 2% below it. Increase gradually.
- Use per-directory thresholds. Core business logic deserves higher coverage than configuration files or route definitions.
- Never write tests solely to increase coverage numbers. Tests should verify behavior. If you need a test to cover a line, make sure it asserts something meaningful about that line.
- Review coverage diffs on pull requests. A PR that adds 200 lines of code and 0% coverage for those lines needs more tests. A PR that reduces overall coverage by 1% on a large codebase might be fine.
- Exclude code that should not be tested. Configuration files, type definitions, and generated code inflate your denominator without adding testing value.
- Run coverage in CI and fail the build on threshold violations. Coverage enforcement works only when it is automated. Manual coverage reviews get skipped under deadline pressure.
- 100% coverage is not a goal — it is occasionally a side effect. Aiming for 100% leads to testing getters, setters, and trivial code. Aim for high coverage on code that matters.