DEV Community

Steve Zhang
Steve Zhang

Posted on

How to Merge Vitest Unit, Component, and E2E Test Coverage

Modern React applications often have multiple test types running in different environments. Each produces its own coverage report. This article shows how to merge them into a single, accurate coverage report.

Tech Stack

This article assumes the following setup:

Layer Technology
Application React + Next.js / Vite / Webpack
Unit Tests Vitest (jsdom environment)
Component Tests Vitest Browser Mode + Playwright
E2E Tests Playwright + nextcov
Coverage Provider V8

The Problem: Separate Coverage Reports

A typical Next.js project might have:

coverage/
├── unit/           # From vitest unit tests (jsdom)
├── component/      # From vitest browser tests
└── e2e/            # From playwright + nextcov
Enter fullscreen mode Exit fullscreen mode

Each directory contains a coverage-final.json file with coverage data for the same source files. But the coverage numbers don't add up correctly if you try to merge them naively.

Why Naive Merging Fails

Consider a simple component:

// src/components/Input.tsx
'use client'

import React from 'react'

export function Input({ error }: { error?: string }) {
  return (
    <div>
      <input />
      {error && <p>{error}</p>}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

When Vitest runs unit tests, it counts the 'use client' directive and import statement as executable statements. When V8 coverage runs during E2E tests, these lines don't exist in the bundled code.

If you merge these reports without accounting for this difference, you get inflated statement counts or incorrect coverage percentages.

Setting Up Separate Coverage Directories

Unit Test Configuration

// vitest.config.mts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react-swc'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
    exclude: ['src/**/*.browser.test.{ts,tsx}'],
    coverage: {
      enabled: true,
      provider: 'v8',
      reportsDirectory: './coverage/unit',
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/__tests__/**',
        'src/**/*.test.{ts,tsx}',
        'src/**/*.browser.test.{ts,tsx}',
      ],
      reporter: ['text', 'json', 'html'],
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Key settings:

  • reportsDirectory: './coverage/unit' — Output to a dedicated directory
  • provider: 'v8' — Use V8 coverage (not Istanbul)
  • reporter: ['json'] — Must include json to generate coverage-final.json

Component Test Configuration

// vitest.component.config.mts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react-swc'
import tsconfigPaths from 'vite-tsconfig-paths'
import { playwright } from '@vitest/browser-playwright'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    globals: true,
    setupFiles: ['./vitest.browser.setup.ts'],
    include: ['src/**/*.browser.test.{ts,tsx}'],
    coverage: {
      enabled: true,
      provider: 'v8',
      reportsDirectory: './coverage/component',
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/__tests__/**',
        'src/**/*.test.{ts,tsx}',
        'src/**/*.browser.test.{ts,tsx}',
      ],
      reporter: ['text', 'json', 'html'],
    },
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: 'chromium' }],
      headless: true,
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Key differences from unit tests:

  • include: ['src/**/*.browser.test.{ts,tsx}'] — Different file pattern
  • reportsDirectory: './coverage/component' — Separate output directory
  • browser.enabled: true — Runs in a real browser

Package.json Scripts

{
  "scripts": {
    "test:unit": "vitest run",
    "test:component": "vitest run --config vitest.component.config.mts",
    "test": "npm run test:unit && npm run test:component",
    "coverage:merge": "nextcov merge coverage/unit coverage/component coverage/e2e -o coverage/merged"
  }
}
Enter fullscreen mode Exit fullscreen mode

The Directive Stripping Problem

Before diving into merge tools, you need to understand why naive merging fails.

When running tests in different environments, V8 coverage produces inconsistent statement counts:

  • Vitest unit tests (jsdom): Counts import statements and directives ('use client', 'use server') as executable
  • Vitest browser tests: Also counts imports as executable statements
  • E2E coverage (bundled code): These statements don't exist in the bundle

Without normalization:

# Vitest unit coverage (counts directives)
Input.tsx: 10 statements, 8 covered (80%)

# E2E coverage (no directives in bundle)
Input.tsx: 8 statements, 6 covered (75%)

# Naive merge (wrong!)
Input.tsx: 10 statements, 14 covered (140%?!)
Enter fullscreen mode Exit fullscreen mode

The solution is to strip import statements and directives before merging, normalizing all sources to the same baseline.

Why Not Use Vitest's Multi-Project Setup?

You might think Vitest's multi-project setup could solve this — run unit and component tests as separate projects and let Vitest merge the coverage automatically.

Unfortunately, there's currently a bug in Vitest where multi-project setups don't properly merge coverage reports. Until this is fixed, you need external tools to merge coverage from different test environments.

Option 1: vitest-coverage-merge (Vitest Only)

For merging just Vitest unit and component test coverage, use vitest-coverage-merge:

npm install -D vitest-coverage-merge
Enter fullscreen mode Exit fullscreen mode
npx vitest-coverage-merge coverage/unit coverage/component -o coverage/merged
Enter fullscreen mode Exit fullscreen mode

What vitest-coverage-merge Does

  1. Loads coverage files from each directory's coverage-final.json
  2. Normalizes by stripping ESM import statements and React/Next.js directives
  3. Intelligently merges while preferring browser test structures
  4. Generates reports (HTML, LCOV, JSON)

This tool specifically addresses the environment-based coverage differences between jsdom and real browser tests — unlike Vitest's built-in --merge-reports which only handles sharded test runs.

Package.json Scripts (Vitest Only)

{
  "scripts": {
    "test:unit": "vitest run",
    "test:component": "vitest run --config vitest.component.config.mts",
    "test": "npm run test:unit && npm run test:component",
    "coverage:merge": "vitest-coverage-merge coverage/unit coverage/component -o coverage/merged"
  }
}
Enter fullscreen mode Exit fullscreen mode

Option 2: nextcov merge (Including E2E)

For merging Vitest coverage with E2E coverage from Playwright + nextcov, use nextcov merge:

npx nextcov merge coverage/unit coverage/component coverage/e2e -o coverage/merged
Enter fullscreen mode Exit fullscreen mode

What nextcov merge Does

  1. Loads coverage files from each directory's coverage-final.json
  2. Strips directives by default ('use client', 'use server', import statements)
  3. Selects the best structure for each file (prefers sources without directive inflation)
  4. Merges execution counts using a "max" strategy
  5. Generates reports (HTML, LCOV, JSON, text-summary)

Disabling Directive Stripping

If your sources don't have directive mismatches, you can disable stripping:

npx nextcov merge coverage/unit coverage/component --no-strip
Enter fullscreen mode Exit fullscreen mode

Adding E2E Coverage

For complete coverage, add E2E test coverage with nextcov:

Playwright Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
import type { NextcovConfig } from 'nextcov'

export const nextcov: NextcovConfig = {
  cdpPort: 9230,
  outputDir: 'coverage/e2e',
  sourceRoot: './src',
  include: ['src/**/*.{ts,tsx}'],
  exclude: [
    'src/**/__tests__/**',
    'src/**/*.test.{ts,tsx}',
    'src/**/*.browser.test.{ts,tsx}',
  ],
  reporters: ['html', 'lcov', 'json', 'text-summary'],
}

export default defineConfig({
  testDir: './e2e',
  reporter: [['list'], ['html'], ['./e2e/coverage-reporter.ts']],
  use: {
    baseURL: 'http://localhost:3000',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
})
Enter fullscreen mode Exit fullscreen mode

Package.json Scripts

{
  "scripts": {
    "build:e2e": "cross-env E2E_MODE=true npm run build",
    "start:e2e": "cross-env E2E_MODE=true NODE_V8_COVERAGE=.v8-coverage NODE_OPTIONS=--inspect=9230 next start",
    "e2e": "npm run e2e:clean && npm run e2e:run",
    "e2e:clean": "rimraf coverage/e2e",
    "e2e:run": "start-server-and-test start:e2e http://localhost:3000 playwright-test",
    "coverage:merge": "nextcov merge coverage/unit coverage/component coverage/e2e -o coverage/merged"
  }
}
Enter fullscreen mode Exit fullscreen mode

See Article 1 for detailed E2E coverage setup.

Merge Strategy Details

Max Strategy (Default)

For each coverage item (statement, branch, function), nextcov takes the maximum count across all sources:

Unit tests:   line 10 executed 5 times
Component:    line 10 executed 3 times
E2E:          line 10 executed 2 times
────────────────────────────────────────
Merged:       line 10 executed 5 times (max)
Enter fullscreen mode Exit fullscreen mode

This is conservative — it reflects the highest observed coverage without double-counting.

Structure Selection

When merging, nextcov must choose which source's "structure" to use (which statements exist, where branches are). It prefers:

  1. Sources without L1:0 directive statements (E2E-style coverage)
  2. Sources with more coverage items (more complete analysis)
  3. Later sources when equal (E2E is typically last)

This ensures the merged report doesn't show inflated statement counts from directive-tracking sources.

Complete Example

Here's a full working setup:

Directory Structure

project/
├── src/
│   ├── components/
│   │   ├── Input.tsx
│   │   └── __tests__/
│   │       ├── Input.test.tsx          # Unit tests
│   │       └── Input.browser.test.tsx  # Component tests
├── e2e/
│   └── form.spec.ts                 # E2E tests
├── coverage/
│   ├── unit/
│   ├── component/
│   ├── e2e/
│   └── merged/
├── vitest.config.mts
├── vitest.component.config.mts
├── playwright.config.ts
└── package.json
Enter fullscreen mode Exit fullscreen mode

Running Tests and Merging

# Run all tests with coverage
npm run test:unit
npm run test:component
npm run e2e

# Merge all coverage
npx nextcov merge coverage/unit coverage/component coverage/e2e -o coverage/merged

# Or use the npm script
npm run coverage:merge
Enter fullscreen mode Exit fullscreen mode

Output

📊 nextcov merge
   Inputs: coverage/unit, coverage/component, coverage/e2e
   Output: coverage/merged
   Reporters: html, lcov, json, text-summary
   Strip directives: yes
   Loading: coverage/unit/coverage-final.json
   Loading: coverage/component/coverage-final.json
   Loading: coverage/e2e/coverage-final.json
   Stripped: 30 imports, 28 directives

=============================== Coverage summary ===============================
Statements   : 89.07% ( 595/668 )
Branches     : 78.06% ( 338/433 )
Functions    : 92.9% ( 131/141 )
Lines        : 88.71% ( 574/647 )
================================================================================

✅ Merged coverage report generated
   Output: coverage/merged
Enter fullscreen mode Exit fullscreen mode

Choosing Report Formats

By default, nextcov generates all report formats. You can customize this:

# Only HTML and LCOV (for CI integration)
npx nextcov merge coverage/unit coverage/e2e --reporters html,lcov

# Only JSON (for further processing)
npx nextcov merge coverage/unit coverage/e2e --reporters json
Enter fullscreen mode Exit fullscreen mode

Available reporters:

  • html — Interactive HTML report
  • lcov — LCOV format for CI tools (Codecov, Coveralls)
  • json — JSON format (coverage-final.json)
  • text-summary — Console summary

Troubleshooting

"No coverage-final.json found"

Ensure your Vitest config includes json in the reporters:

coverage: {
  reporter: ['text', 'json', 'html'],  // Must include 'json'
}
Enter fullscreen mode Exit fullscreen mode

Coverage percentages seem wrong after merge

This usually means directive stripping didn't work as expected. Check:

  1. Are you using V8 coverage (provider: 'v8') in all sources?
  2. Do your source files have 'use client' or 'use server' directives?
  3. Try running with --no-strip to see the raw merge

Some files missing from merged report

Files only appear if they're in at least one source. If E2E tests don't exercise a file, it won't appear in E2E coverage. The merge will still include it from unit/component coverage.

Summary

Step Command Output
Unit tests npm run test:unit coverage/unit/
Component tests npm run test:component coverage/component/
E2E tests npm run e2e coverage/e2e/
Merge all npx nextcov merge coverage/unit coverage/component coverage/e2e coverage/merged/

Key points:

  • Use separate reportsDirectory for each test type
  • Always include json reporter to generate coverage-final.json
  • Use vitest-coverage-merge for Vitest-only merging, or nextcov merge to include E2E coverage
  • Both tools strip directives to normalize coverage data before merging

References


This is part 5 of a series on test coverage for modern React applications:

  1. nextcov - Collecting Test Coverage for Next.js Server Components
  2. Why Istanbul Coverage Doesn't Work with Next.js App Router
  3. V8 Coverage vs Istanbul: Performance and Accuracy
  4. V8 Coverage Limitations and How to Work Around Them
  5. How to Merge Vitest Unit, Component, and E2E Test Coverage (this article)
  6. E2E Coverage in Next.js: Dev Mode vs Production Mode

Top comments (0)