Testing — Code Coverage
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 tools track four metrics. Understanding the difference between them is essential for interpreting coverage reports correctly.
| 1 | // Example code for coverage analysis |
| 2 | function 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 |
| Metric | What It Measures | Significance |
|---|---|---|
| Statements | Individual executable statements | Most basic metric, least useful alone |
| Branches | Each branch of conditionals (if/else, switch) | Most important — catches logic gaps |
| Functions | Function/method calls | Identifies unused or untested code paths |
| Lines | Executable lines of code | Similar to statements, easy to misinterpret |
info
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.
| 1 | // How Istanbul instruments code (simplified) |
| 2 | // Original code: |
| 3 | function add(a: number, b: number): number { |
| 4 | return a + b; |
| 5 | } |
| 6 | |
| 7 | // Instrumented code (what Istanbul generates): |
| 8 | const __cov = (global.__coverage__ || (global.__coverage__ = {})); |
| 9 | const __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 | |
| 21 | function 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
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.
| 1 | // vitest.config.ts — coverage configuration |
| 2 | import { defineConfig } from "vitest/config"; |
| 3 | |
| 4 | export 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
Jest has built-in coverage support using Istanbul under the hood. Configuration is done via the collectCoverage and coverageThreshold options.
| 1 | // jest.config.ts — coverage configuration |
| 2 | import type { Config } from "jest"; |
| 3 | |
| 4 | const 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 | |
| 64 | export 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 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.
| 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 |
| 52 | thresholdAutoUpdate: true, |
| 53 | // This writes updated thresholds back to config |
| 54 | // Review changes before committing! |
info
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.
| 1 | # Coverage report types and commands |
| 2 | |
| 3 | # Text report — quick overview in terminal |
| 4 | npx 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 |
| 18 | npx 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 |
| 31 | npx vitest --coverage --reporter=html |
| 32 | |
| 33 | # LCOV report — IDE integration |
| 34 | # VS Code: coverage-gutters, Jest Runner |
| 35 | # IntelliJ: built-in coverage viewer |
| 36 | npx vitest --coverage --reporter=lcov |
| 37 | |
| 38 | # JSON report — for CI tools |
| 39 | npx vitest --coverage --reporter=json |
info
CI integration enforces coverage thresholds automatically on every pull request. This prevents coverage regressions and provides visibility into test quality trends over time.
| 1 | # GitHub Actions — coverage workflow |
| 2 | # .github/workflows/coverage.yml |
| 3 | name: Coverage |
| 4 | on: [pull_request] |
| 5 | |
| 6 | jobs: |
| 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-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.
| 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 |
| 16 | export 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 |
| 31 | test("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 | |
| 37 | test("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
- 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
- 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