|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/coverage
$cat docs/testing-—-code-coverage.md
updated Recently·12 min read·published

Testing — Code Coverage

TestingIntermediate
Introduction

Code coverage measures how much of your source code is executed during testing. It is a quantitative metric that helps identify untested code paths, but it says nothing about the quality of your tests. A 100% coverage can coexist with terrible tests, and 60% coverage can coexist with excellent tests.

Coverage data is collected by instrumenting your code with probes that track which lines, branches, and functions are executed. The most common instrumentation library is Istanbul (and its CLI tool nyc), which powers coverage collection for both Vitest and Jest.

Coverage Metrics Explained

Coverage tools track four metrics. Understanding the difference between them is essential for interpreting coverage reports correctly.

coverage-metrics.ts
TypeScript
1// Example code for coverage analysis
2function processTransaction(
3 amount: number,
4 type: "credit" | "debit",
5 isPremium: boolean
6): { fee: number; netAmount: number } {
7 // STATEMENT 1 (line 5): function declaration
8 // STATEMENT 2 (line 6): variable declaration
9
10 let fee = 0; // STATEMENT 3
11
12 // BRANCH 1: if (type === "credit")
13 if (type === "credit") { // BRANCH 2: if (amount > 10000)
14 if (amount > 10000) { // STATEMENT 4
15 fee = amount * 0.01; // STATEMENT 5
16 } else { // STATEMENT 6
17 fee = amount * 0.005; // STATEMENT 7
18 }
19 }
20
21 // BRANCH 3: if (isPremium)
22 if (isPremium) { // STATEMENT 8
23 fee = fee * 0.5; // STATEMENT 9
24 }
25
26 return { fee, netAmount: amount - fee }; // STATEMENT 10
27}
28
29// Coverage metrics for this function:
30// Statements: 10 total, N executed = statement coverage %
31// Branches: 3 total (if/else), N taken = branch coverage %
32// Functions: 1 total, called? = function coverage %
33// Lines: 10 total, N lines hit = line coverage %
34
35// If we test with: processTransaction(100, "credit", false)
36// Statements executed: 1, 2, 3, 4, 6, 7, 8 (isPremium = false, skip 9), 10
37// - Statement coverage: 8/10 = 80%
38// - Branch coverage: 1/3 = 33% (only "credit" and first amount branch taken)
39// - Function coverage: 1/1 = 100%
40// - Line coverage: 8/10 = 80%
41
42// Missing: amount > 10000 branch, isPremium = true branch
MetricWhat It MeasuresSignificance
StatementsIndividual executable statementsMost basic metric, least useful alone
BranchesEach branch of conditionals (if/else, switch)Most important — catches logic gaps
FunctionsFunction/method callsIdentifies unused or untested code paths
LinesExecutable lines of codeSimilar to statements, easy to misinterpret

info

Branch coverage is the most meaningful metric. It tells you whether you tested both the true and false paths of every conditional. A function with 100% statement coverage but 50% branch coverage indicates untested edge cases.
Istanbul & nyc

Istanbul is the JavaScript code coverage tool that powers coverage collection for most test frameworks. It works by instrumenting your code with counters that track execution. The nyc CLI is the command-line interface for running Istanbul.

istanbul-instrumentation.ts
TypeScript
1// How Istanbul instruments code (simplified)
2// Original code:
3function add(a: number, b: number): number {
4 return a + b;
5}
6
7// Instrumented code (what Istanbul generates):
8const __cov = (global.__coverage__ || (global.__coverage__ = {}));
9const __path = "src/utils/math.ts";
10
11// Coverage counters: [statements, branches, functions, lines]
12// Each counter tracks: [hitCount, ...paths]
13__cov[__path] = __cov[__path] || {
14 path: __path,
15 s: { "1": 0, "2": 0 }, // Statements
16 b: { "1": [0, 0] }, // Branches (true/false counts)
17 f: { "1": 0 }, // Functions
18 l: { "1": 0, "2": 0 }, // Lines
19};
20
21function add(a: number, b: number): number {
22 __cov[__path].f["1"]++; // Function entered
23 __cov[__path].s["1"]++; // First statement hit
24 __cov[__path].l["1"]++; // First line hit
25 const result = a + b;
26 return result;
27 __cov[__path].s["2"]++; // Second statement hit
28 __cov[__path].l["2"]++; // Second line hit
29}
30
31// Using nyc CLI directly (not through test framework)
32// npx nyc --reporter=html --reporter=text node test.js
33
34// Istanbul configuration via .nycrc
35{
36 "extends": "@istanbuljs/nyc-config-typescript",
37 "all": true,
38 "include": ["src/**/*.ts"],
39 "exclude": ["**/*.test.ts", "**/*.spec.ts"],
40 "reporter": ["text", "html", "lcov", "json"],
41 "check-coverage": true,
42 "branches": 80,
43 "functions": 80,
44 "lines": 80,
45 "statements": 80,
46 "report-dir": "./coverage"
47}

info

While Istanbul is the industry standard, Vitest's v8 coverage provider is significantly faster (5-10x) than Istanbul for most projects. Use provider: "v8" in Vitest for faster coverage collection. Switch to Istanbul only if you need specific Istanbul features that v8 does not support.
Configuring Coverage in Vitest

Vitest supports coverage via the built-in @vitest/coverage-v8 or @vitest/coverage-istanbul providers. Configuration is done in vitest.config.ts under the test.coverage key.

vitest-coverage.ts
TypeScript
1// vitest.config.ts — coverage configuration
2import { defineConfig } from "vitest/config";
3
4export default defineConfig({
5 test: {
6 coverage: {
7 // Provider: "v8" (faster) or "istanbul" (more features)
8 provider: "v8",
9
10 // Report formats
11 reporter: [
12 "text", // Console summary
13 "text-summary", // Brief console summary
14 "html", // Interactive HTML report
15 "lcov", // For IDE integration
16 "json", // For CI tools
17 "clover", // For Jenkins
18 ],
19
20 // Output directory
21 reportsDirectory: "./coverage",
22
23 // Files to include
24 include: [
25 "src/**/*.{ts,tsx}",
26 "!src/**/*.test.{ts,tsx}",
27 "!src/**/*.spec.{ts,tsx}",
28 "!src/**/__tests__/**",
29 ],
30
31 // Files to exclude
32 exclude: [
33 "src/**/*.d.ts",
34 "src/**/types.ts",
35 "src/vite-env.d.ts",
36 "**/node_modules/**",
37 "**/dist/**",
38 ],
39
40 // Coverage thresholds (enforced)
41 thresholds: {
42 statements: 80,
43 branches: 75,
44 functions: 80,
45 lines: 80,
46
47 // Per-file thresholds (new in Vitest 1.x)
48 perFile: true,
49 // Allow specific files to have lower thresholds
50 "src/config/*.ts": {
51 statements: 50,
52 branches: 50,
53 },
54 },
55
56 // Watermarks for HTML report coloring
57 watermarks: {
58 statements: [50, 80],
59 functions: [50, 80],
60 branches: [50, 80],
61 lines: [50, 80],
62 },
63
64 // Include uncovered files in report
65 all: true,
66
67 // Clean coverage directory before each run
68 clean: true,
69
70 // Threshold check level
71 thresholdAutoUpdate: false,
72 },
73 },
74});
75
76// CLI usage:
77// npx vitest --coverage # Run tests with coverage
78// npx vitest --coverage --reporter=json # JSON coverage report
79// npx vitest --coverage.enabled=true # Enable coverage only for this run

warning

Setting all: true includes files that are never imported by tests. This is useful for identifying completely untested files, but it will lower your coverage percentage and may cause threshold failures. Use it intentionally and adjust thresholds accordingly.
Configuring Coverage in Jest

Jest has built-in coverage support using Istanbul under the hood. Configuration is done via the collectCoverage and coverageThreshold options.

jest-coverage.ts
TypeScript
1// jest.config.ts — coverage configuration
2import type { Config } from "jest";
3
4const config: Config = {
5 // Enable coverage collection
6 collectCoverage: true,
7
8 // Files to collect coverage from
9 collectCoverageFrom: [
10 "src/**/*.{ts,tsx}",
11 "!src/**/*.d.ts",
12 "!src/**/*.test.{ts,tsx}",
13 "!src/**/*.spec.{ts,tsx}",
14 "!src/**/__tests__/**",
15 "!src/**/types/**",
16 "!src/**/index.ts",
17 ],
18
19 // Coverage directory
20 coverageDirectory: "coverage",
21
22 // Coverage reporters
23 coverageReporters: [
24 "text",
25 "text-summary",
26 "html",
27 "lcov",
28 "json",
29 "clover",
30 "cobertura", // XML for CI tools
31 ],
32
33 // Coverage thresholds that will fail the build
34 coverageThreshold: {
35 global: {
36 statements: 80,
37 branches: 75,
38 functions: 80,
39 lines: 80,
40 },
41 // Per-directory thresholds
42 "./src/components/": {
43 statements: 85,
44 branches: 80,
45 },
46 "./src/utils/": {
47 statements: 90,
48 branches: 85,
49 },
50 },
51
52 // Path to exclude from coverage
53 coveragePathIgnorePatterns: [
54 "/node_modules/",
55 "/dist/",
56 "/.next/",
57 ],
58
59 // Provider (Jest 29+)
60 // "babel" (default) or "v8"
61 coverageProvider: "babel",
62};
63
64export default config;
65
66// CLI usage:
67// npx jest --coverage # Run with coverage
68// npx jest --coverage --no-cache # Clear cache
69// npx jest --coverage --verbose # Verbose output
Coverage Thresholds

Coverage thresholds enforce minimum coverage levels. When a threshold is not met, the test suite fails. This prevents coverage regressions from reaching production but requires careful setup to avoid false positives.

threshold-strategies.ts
TypeScript
1// Coverage threshold strategies
2
3// Strategy 1: Start low, increase gradually
4// Target: Increase by 2% per sprint until reaching 80%
5{
6 thresholds: {
7 statements: 60, // Start at 60%
8 branches: 50, // Start at 50%
9 functions: 60,
10 lines: 60,
11 }
12}
13
14// Strategy 2: Per-directory thresholds
15// Set higher thresholds for core logic, lower for configuration
16{
17 thresholds: {
18 global: {
19 statements: 70,
20 branches: 60,
21 },
22 "./src/core/": {
23 statements: 90,
24 branches: 85,
25 },
26 "./src/pages/": {
27 statements: 60,
28 branches: 50,
29 },
30 "./src/config/": {
31 statements: 30, // Config files may have complex init logic
32 },
33 }
34}
35
36// Strategy 3: Per-file overrides (Vitest)
37// Certain files legitimately have low coverage
38{
39 thresholds: {
40 perFile: true,
41 statements: 80,
42 // Allow specific files to fall below threshold
43 "src/**/*.config.*": { statements: 0 }, // Config files
44 "src/**/*.stories.*": { statements: 0 }, // Storybook
45 "src/legacy/**": { statements: 50 }, // Legacy code
46 }
47}
48
49// Strategy 4: Threshold auto-update (Vitest experimental)
50// Automatically adjusts thresholds to current coverage
51// Prevents CI failures when coverage naturally fluctuates
52thresholdAutoUpdate: true,
53// This writes updated thresholds back to config
54// Review changes before committing!

info

Do not set thresholds to 100% on an existing codebase. You will immediately fail CI and have to disable thresholds entirely. Start at your current coverage level minus 5%, then increase by 1-2% per sprint. This creates a sustainable upward trend.
Coverage Reports

Coverage reports visualize which lines of code are covered by tests. Different report formats serve different purposes — console reports for quick feedback, HTML for exploration, and machine-readable formats for CI integration.

coverage-reports.sh
Bash
1# Coverage report types and commands
2
3# Text report — quick overview in terminal
4npx vitest --coverage --reporter=text
5# Output:
6# ---------------|---------|----------|---------|---------|-------------------
7# File | % Stmts | % Branch | % Funcs | % Lines | Uncovered # lines
8# ---------------|---------|----------|---------|---------|-------------------
9# All files | 82.3 | 74.1 | 85.7 | 82.3 |
10# src/ | 80.0 | 70.0 | 83.3 | 80.0 |
11# utils.ts | 95.0 | 90.0 | 100.0 | 95.0 | 15, 23, 47
12# api.ts | 70.0 | 60.0 | 75.0 | 70.0 | 8-12, 34-38
13# components/ | 85.0 | 80.0 | 90.0 | 85.0 |
14# Button.tsx | 90.0 | 85.0 | 100.0 | 90.0 | 42
15# Modal.tsx | 75.0 | 70.0 | 80.0 | 75.0 | 22, 56-60
16
17# Text summary — condensed version
18npx vitest --coverage --reporter=text-summary
19# Output:
20# =============================== Coverage summary ===============================
21# Statements : 82.3% ( 245/298 )
22# Branches : 74.1% ( 86/116 )
23# Functions : 85.7% ( 48/56 )
24# Lines : 82.3% ( 238/289 )
25# ===============================================================================
26
27# HTML report — interactive browser view
28# Open coverage/index.html in browser
29# Color coding: green (covered), yellow (partial), red (uncovered)
30# Click through to see line-by-line coverage
31npx vitest --coverage --reporter=html
32
33# LCOV report — IDE integration
34# VS Code: coverage-gutters, Jest Runner
35# IntelliJ: built-in coverage viewer
36npx vitest --coverage --reporter=lcov
37
38# JSON report — for CI tools
39npx vitest --coverage --reporter=json

info

Install the VS Code extension "Coverage Gutters" to display coverage highlighting directly in your editor. It reads LCOV files and shows green/red gutters next to covered/uncovered lines, making it easy to identify untested code while you work.
CI Integration

CI integration enforces coverage thresholds automatically on every pull request. This prevents coverage regressions and provides visibility into test quality trends over time.

ci-coverage.yml
YAML
1# GitHub Actions — coverage workflow
2# .github/workflows/coverage.yml
3name: Coverage
4on: [pull_request]
5
6jobs:
7 coverage:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 - uses: actions/setup-node@v4
12 - run: npm ci
13
14 # Run tests with coverage
15 - run: npx vitest --coverage
16
17 # Upload coverage report as artifact
18 - uses: actions/upload-artifact@v4
19 with:
20 name: coverage-report
21 path: coverage/
22
23 # Upload to code coverage service
24 - name: Upload to Codecov
25 uses: codecov/codecov-action@v3
26 with:
27 directory: ./coverage
28 fail_ci_if_error: true
29 verbose: true
30
31 # Upload to Coveralls
32 - name: Upload to Coveralls
33 uses: coverallsapp/github-action@v2
34 with:
35 file: ./coverage/lcov.info
36
37 # Comment PR with coverage summary
38 - name: Coverage PR Comment
39 uses: davelosert/vitest-coverage-report-action@v2
40 with:
41 json-summary-path: ./coverage/coverage-summary.json
42
43# Enforce coverage with branch protection
44# Settings → Branches → Add branch protection rule
45# Require status checks to pass before merging
46# Add "Coverage" check — fails if thresholds not met
47
48# Alternative: Coverage gate via GitHub status check
49# Combine with any coverage service that supports status checks
50# Codecov, Coveralls, SonarCloud all create status check comments

warning

Coverage as a CI gate is a double-edged sword. If thresholds are set too aggressively, developers work around them (writing meaningless tests, excluding files). If set too leniently, they provide no signal. Review coverage trends over weeks, not individual PR fluctuations.
Coverage-Driven Development

Coverage-Driven Development (CDD) is the practice of using coverage information to guide test writing. Unlike TDD (test first) or traditional testing (code first), CDD uses coverage reports as a feedback mechanism to identify untested code and prioritize test creation.

  • Identify untested branches — Coverage reports highlight conditional branches that are not exercised. These are often edge cases that cause production bugs.
  • Find dead code — Functions or modules with 0% coverage may be dead code. Investigate and either add tests or remove the code.
  • Prioritize high-risk uncovered code — Use coverage data to identify critical paths (auth, payment, data validation) that lack tests.
  • Track coverage trends — Coverage should trend upward over time. A sudden drop indicates new untested code entering the codebase.
  • Use coverage to inform code review — When reviewing a PR, check if the new code is covered by tests. Low-coverage PRs should be scrutinized.
coverage-driven-dev.ts
TypeScript
1// Coverage-driven development workflow
2
3// 1. Run coverage report — identify red (uncovered) lines
4// npx vitest --coverage --reporter=html
5// Open coverage/index.html — find red sections
6
7// 2. Prioritize uncovered code by risk:
8// HIGH: Auth, payments, data validation, error boundaries
9// MEDIUM: Business logic, API clients, state management
10// LOW: UI components, formatting, configuration
11
12// 3. Write targeted tests for uncovered code
13// Before: coverage report shows uncovered error handling
14
15// src/api/client.ts
16export async function apiRequest<T>(url: string): Promise<T> {
17 try {
18 const response = await fetch(url);
19 if (!response.ok) {
20 throw new ApiError(response.status, await response.text());
21 }
22 return response.json();
23 } catch (error) {
24 // This catch block shows as uncovered in coverage
25 if (error instanceof ApiError) throw error;
26 throw new NetworkError("Network request failed");
27 }
28}
29
30// After: add tests covering the error path
31test("throws NetworkError on fetch failure", async () => {
32 mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch"));
33
34 await expect(apiRequest("/api/data")).rejects.toThrow(NetworkError);
35});
36
37test("throws ApiError on non-OK response", async () => {
38 mockFetchResponse({ error: "Forbidden" }, 403);
39
40 await expect(apiRequest("/api/admin")).rejects.toThrow(ApiError);
41});
42
43// 4. Re-run coverage — the red lines should turn green
44// Coverage: branches 60% → 80%, statements 80% → 85%

info

Use the "hotspots" approach: focus on uncovered files that have the highest complexity (cyclomatic complexity). A low-coverage, high-complexity file is a bug waiting to happen. Most coverage tools can sort by complexity to identify these hotspots.
Coverage Anti-Patterns
  • Chasing 100% coverage — 100% coverage does not mean bug-free code. Time spent reaching 100% is better spent on integration tests and edge case analysis.
  • Writing tests to increase coverage — Tests should verify behavior, not hit counters. A test that exists only to increase coverage without meaningful assertions is worse than no test.
  • Excluding complex files from coverage — The files most likely to contain bugs should have the highest coverage requirements, not be excluded.
  • Ignoring branch coverage — Statement coverage can be 100% while half your conditionals are untested. Always monitor branch coverage separately.
  • Comparing coverage across projects — A 90% coverage on a simple UI project is not comparable to 70% coverage on a complex data processing project. Compare within your project over time.
  • Setting thresholds without trend tracking — A static threshold tells you if you passed today. Trend tracking tells you if you are improving over time.

info

The most important coverage number is not your total percentage — it is the coverage on new code added in this pull request. Enforce that new code has >90% coverage. This grows total coverage naturally without requiring rewrites of legacy code.
Best Practices
  • Use branch coverage as your primary metric — It is the best indicator of thorough testing. Set branch coverage thresholds higher than statement thresholds.
  • Track coverage trends, not snapshots — A 2% drop over a month is more concerning than a 2% drop in one PR. Use dashboards to monitor trends.
  • Integrate coverage into code review — Tools like Codecov and Coveralls comment on PRs with coverage changes. Require coverage checks for critical code paths.
  • Separate unit and integration coverage — Run coverage separately for unit tests and integration tests. This helps identify gaps in each testing layer.
  • Use the v8 provider in Vitest — It is 5-10x faster than Istanbul. Speed matters because fast coverage feedback encourages developers to check it regularly.
  • Coverage is a tool, not a goal — The goal is reliable software. Coverage is one data point among many (bug rates, test reliability, deployment frequency).

info

The best coverage practice is to make coverage information visible but not punitive. Show coverage in PR comments, dashboards, and IDE overlays. Let developers see the gaps and close them willingly rather than forcing thresholds that invite gaming.
$Blueprint — Engineering Documentation·Section ID: TEST-COV·Revision: 1.0