How to Merge Vitest Unit, Component, and E2E Test Coverage
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
| 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
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 theimportstatement 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 directoryprovider: 'v8'– use V8 coverage (not Istanbul)reporter: ['json']– must includejsonto generatecoverage-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 patternreportsDirectory: './coverage/component'– separate output directorybrowser.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:
| Environment | What is counted as executable |
|---|---|
| Vitest unit tests (jsdom) | import statements and directives ('use client', 'use server') |
| Vitest browser tests | Same 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.jsonfrom 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.jsonfrom 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:
- Sources without L1:0 directive statements (E2E‑style coverage)
- Sources with more coverage items (more complete analysis)
- 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
| Reporter | Description |
|---|---|
html | Interactive HTML report |
lcov | LCOV format for CI tools (Codecov, etc.) |
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:
// 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-stripto 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
| 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/ |