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

Published: (January 1, 2026 at 03:19 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

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

LayerTechnology
ApplicationReact + Next.js / Vite / Webpack
Unit TestsVitest (jsdom environment)
Component TestsVitest Browser Mode + Playwright
E2E TestsPlaywright + nextcov
Coverage ProviderV8

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

Each directory contains a coverage-final.json file with coverage data for the same source files. Merging them naïvely produces incorrect numbers.

Why Naïve Merging Fails

Consider a simple component:

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

import React from 'react'

export function Input({ error }: { error?: string }) {
  return (
    <div>
      {error && <span>{error}</span>}
    </div>
  )
}
  • Vitest unit tests count the 'use client' directive and the import statement as executable statements.
  • V8 coverage during E2E tests runs on bundled code where those lines are stripped.

Merging without accounting for this difference inflates statement counts and skews 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'],
    },
  },
})

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,
    },
  },
})

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"
  }
}

The Directive Stripping Problem

V8 coverage counts differ across environments:

EnvironmentWhat is counted as executable
Vitest unit tests (jsdom)import statements and directives ('use client', 'use server')
Vitest browser testsSame as unit tests
E2E coverage (bundled)Directives and imports are stripped during bundling

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%)

# Naïve merge (wrong!)
Input.tsx: 10 statements, 14 covered (140%?!)

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?

Vitest’s multi‑project setup could run unit and component tests as separate projects and merge coverage automatically. Unfortunately, a bug in Vitest prevents proper merging. Until it’s fixed, external tools are required.

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

Installation

npm install -D vitest-coverage-merge

Merge Command

npx vitest-coverage-merge coverage/unit coverage/component -o coverage/merged

What It Does

  • Loads coverage-final.json from each directory
  • Normalizes by stripping ESM import statements and React/Next.js directives
  • Merges intelligently, preferring browser‑test structures
  • Generates HTML, LCOV, and JSON reports

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.

Updated 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"
  }
}

Option 2: nextcov merge (Including E2E)

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

npx nextcov merge coverage/unit coverage/component coverage/e2e -o coverage/merged

What It Does

  • Loads coverage-final.json from each directory
  • Strips directives by default ('use client', 'use server', import statements)
  • Selects the best structure for each file (prefers sources without directive inflation)
  • Merges execution counts using a “max” strategy
  • Generates HTML, LCOV, JSON, and text‑summary reports

Disabling Directive Stripping

If your sources don’t have directive mismatches:

npx nextcov merge coverage/unit coverage/component --no-strip

Adding E2E Coverage

Playwright Configuration (playwright.config.ts)

// 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'] },
    },
  ],
});

E2E 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"
  }
}

See Article 1 for a 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 tests: line 10 executed 3 times
E2E tests:       line 10 executed 2 times
-------------------------------------------------
Merged:          line 10 executed 5 times (max)

This conservative approach reflects the highest observed coverage without double‑counting.

Structure Selection

When merging, nextcov chooses which source’s structure to use (which statements exist, where branches are). Preference order:

  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 prevents inflated statement counts from directive‑tracking sources.

Complete Example

Directory Layout

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

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

Sample 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
   Merged: 1234 statements, 567 branches, 89 functions
   Reports written to coverage/merged

Coverage Summary

: 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.90% ( 131/141 )
Lines        : 88.71% ( 574/647 )
===============================================================================

✅ Merged coverage report generated
   Output: coverage/merged

Choosing Report Formats

By default, nextcov generates all formats. Customize as needed:

# 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

Available Reporters

ReporterDescription
htmlInteractive HTML report
lcovLCOV format for CI tools (Codecov, etc.)
jsonJSON format (coverage-final.json)
text-summaryConsole summary

Troubleshooting

“No coverage‑final.json found”

Ensure your Vitest config includes json in the reporters:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      reporter: ['text', 'json', 'html'], // Must include 'json'
    },
  },
});

Coverage percentages look wrong after merge

Usually caused by directive stripping not working as expected. Verify:

  • All sources use V8 coverage (provider: 'v8')
  • Source files contain 'use client' or 'use server' directives
  • Try merging with --no-strip to see the raw data

Some files missing from merged report

A file appears only if it exists in at least one source. If E2E tests never touch a file, it will still be included from unit/component coverage.

Summary

StepCommandOutput
Unit testsnpm run test:unitcoverage/unit/
Component testsnpm run test:componentcoverage/component/
E2E testsnpm run e2ecoverage/e2e/
Merge allnpx nextcov merge coverage/unit coverage/component coverage/e2ecoverage/merged/
Back to Blog

Related posts

Read more »

The RGB LED Sidequest 💡

markdown !Jennifer Davishttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%...

Mendex: Why I Build

Introduction Hello everyone. Today I want to share who I am, what I'm building, and why. Early Career and Burnout I started my career as a developer 17 years a...