Merge Vitest unit and browser component test coverage with automatic normalization
npm install vitest-coverage-mergeMerge Vitest coverage from unit tests (jsdom) and browser component tests.
> 📝 UPDATE (January 2025):
>
> As of v0.2.0, normalization is now OFF by default. Use --normalize if you need to strip import statements and directives.
When running Vitest with both jsdom (unit tests) and browser mode (component tests), the coverage reports have different statement counts:
| Environment | Import Handling |
|-------------|-----------------|
| jsdom | V8 doesn't count imports as statements |
| Real browser | V8 counts imports as executable statements |
This makes it impossible to accurately merge coverage without normalization.
vitest-coverage-merge provides smart merging of coverage data. When you encounter statement count mismatches, you can use the --normalize flag to strip import statements and Next.js directives ('use client', 'use server') before merging.
``bash`
npm install -D vitest-coverage-merge
`bashMerge unit and component coverage
npx vitest-coverage-merge coverage/unit coverage/component -o coverage/merged
$3
`
vitest-coverage-merge [dir3...] -o `$3
`typescript
import { mergeCoverage, normalizeCoverage } from 'vitest-coverage-merge'// Merge coverage directories
const result = await mergeCoverage({
inputDirs: ['coverage/unit', 'coverage/component'],
outputDir: 'coverage/merged',
normalize: false, // default (set to true to strip imports/directives)
reporters: ['json', 'lcov', 'html'], // default
})
console.log(result.statements.pct) // e.g., 85.5
`Example Vitest Setup
$3
`typescript
import { defineConfig } from 'vitest/config'export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/*/.test.{ts,tsx}'],
exclude: ['src/*/.browser.test.{ts,tsx}'],
coverage: {
enabled: true,
provider: 'v8',
reportsDirectory: './coverage/unit',
reporter: ['json', 'lcov', 'html'],
},
},
})
`$3
`typescript
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'export default defineConfig({
test: {
include: ['src/*/.browser.test.{ts,tsx}'],
coverage: {
enabled: true,
provider: 'v8',
reportsDirectory: './coverage/component',
reporter: ['json', 'lcov', 'html'],
},
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
})
`$3
`json
{
"scripts": {
"test": "npm run test:unit && npm run test:component",
"test:unit": "vitest run",
"test:component": "vitest run --config vitest.component.config.ts",
"coverage:merge": "vitest-coverage-merge coverage/unit coverage/component -o coverage/merged"
}
}
`Output
The tool generates:
-
coverage-final.json - Istanbul coverage data
- lcov.info - LCOV format for CI tools
- index.html - HTML report (in lcov-report folder)How It Works
1. Load coverage-final.json from each input directory
2. Normalize (optional, with
--normalize flag) by stripping:
- ESM import statements (import ... from '...')
- React/Next.js directives ('use client', 'use server') - if present
3. Smart merge using one of two strategies:
- Default (no --normalize): "More items wins" - prefers source with more coverage items, giving you the union of all structures
- With --normalize: "Fewer items wins" - prefers sources without directive statements (browser-style coverage)
4. Merge execution counts using max strategy (takes highest count for each item)
5. Generate reports (JSON, LCOV, HTML)> Note: This tool works with any ESM-based Vitest project (React, Vue, Svelte, vanilla JS/TS, etc.). The React/Next.js directive stripping only applies if those directives are present in your codebase - for non-React projects, it simply has no effect.
Why Not Use Vitest's Built-in Merge?
Vitest's
--merge-reports` is designed for sharded test runs, not for merging coverage from different environments (jsdom vs browser). It doesn't handle the statement count differences caused by how V8 treats imports differently in each environment.- nextcov - E2E coverage collection for Next.js with Playwright
- @vitest/coverage-v8 - V8 coverage provider for Vitest
MIT