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
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>
)
}
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'],
},
},
})
Key settings:
-
reportsDirectory: './coverage/unit'— Output to a dedicated directory -
provider: '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 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
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
importstatements 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%?!)
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
npx vitest-coverage-merge coverage/unit coverage/component -o coverage/merged
What vitest-coverage-merge Does
-
Loads coverage files from each directory's
coverage-final.json - Normalizes by stripping ESM import statements and React/Next.js directives
- Intelligently merges while preferring browser test structures
- 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"
}
}
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
What nextcov merge Does
-
Loads coverage files from each directory's
coverage-final.json -
Strips directives by default (
'use client','use server',importstatements) - Selects the best structure for each file (prefers sources without directive inflation)
- Merges execution counts using a "max" strategy
- 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
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'] },
},
],
})
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"
}
}
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)
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:
- 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 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
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
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
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
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'
}
Coverage percentages seem wrong after merge
This usually means directive stripping didn't work as expected. Check:
- Are you using V8 coverage (
provider: 'v8') in all sources? - Do your source files have
'use client'or'use server'directives? - Try running with
--no-stripto 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
reportsDirectoryfor each test type - Always include
jsonreporter to generatecoverage-final.json - Use
vitest-coverage-mergefor Vitest-only merging, ornextcov mergeto include E2E coverage - Both tools strip directives to normalize coverage data before merging
References
- vitest-coverage-merge — Merge Vitest unit and component test coverage
- nextcov — E2E coverage for Next.js with Playwright
- Vitest Coverage — Official Vitest coverage documentation
- @vitest/browser — Browser testing with Vitest
This is part 5 of a series on test coverage for modern React applications:
- nextcov - Collecting Test Coverage for Next.js Server Components
- Why Istanbul Coverage Doesn't Work with Next.js App Router
- V8 Coverage vs Istanbul: Performance and Accuracy
- V8 Coverage Limitations and How to Work Around Them
- How to Merge Vitest Unit, Component, and E2E Test Coverage (this article)
- E2E Coverage in Next.js: Dev Mode vs Production Mode
Top comments (0)