如何合并 Vitest 单元、组件和 E2E 测试覆盖率
Source: Dev.to
请提供您希望翻译的具体文本内容,我将为您翻译成简体中文。
现代 React 应用通常在不同环境中运行多种测试类型。每种测试都会生成自己的覆盖率报告。本文展示如何将它们合并为单一、准确的覆盖率报告。
技术栈
| 层级 | 技术 |
|---|---|
| 应用程序 | React + Next.js / Vite / Webpack |
| 单元测试 | Vitest(jsdom 环境) |
| 组件测试 | Vitest 浏览器模式 + Playwright |
| 端到端测试 | Playwright + nextcov |
| 覆盖率提供者 | V8 |
问题:分离的覆盖报告
一个典型的 Next.js 项目可能包含:
coverage/
├── unit/ # From vitest unit tests (jsdom)
├── component/ # From vitest browser tests
└── e2e/ # From playwright + nextcov
每个目录都包含一个 coverage-final.json 文件,其中包含相同源文件的覆盖率数据。直接合并它们会产生不正确的数字。
为什么朴素合并会失败
考虑一个简单的组件:
// src/components/Input.tsx
'use client'
import React from 'react'
export function Input({ error }: { error?: string }) {
return (
<div>
{error && <span>{error}</span>}
</div>
)
}
- Vitest 单元测试会将
'use client'指令和import语句计为可执行语句。 - 在 E2E 测试期间,V8 覆盖率运行在已打包的代码上,这些行已被剥除。
在未考虑此差异的情况下合并会导致语句计数膨胀,进而扭曲覆盖率百分比。
Source: …
设置单独的覆盖率目录
单元测试配置
// 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'],
},
},
})
关键设置
reportsDirectory: './coverage/unit'– 输出到专用目录provider: 'v8'– 使用 V8 覆盖率(而非 Istanbul)reporter: ['json']– 必须包含json以生成coverage-final.json
组件测试配置
// 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,
},
},
})
与单元测试的关键区别
include: ['src/**/*.browser.test.{ts,tsx}']– 不同的文件匹配模式reportsDirectory: './coverage/component'– 单独的输出目录browser.enabled: true– 在真实浏览器中运行
package.json 脚本
{
"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"
}
}
指令剥离问题
V8 覆盖率在不同环境下计数不同:
| 环境 | 被视为可执行的内容 |
|---|---|
| Vitest 单元测试 (jsdom) | import 语句和指令('use client'、'use server') |
| Vitest 浏览器测试 | 同单元测试 |
| E2E 覆盖(打包后) | 在打包过程中指令和 import 被剥除 |
未进行归一化时
# Vitest 单元覆盖(计入指令)
Input.tsx: 10 statements, 8 covered (80%)
# E2E 覆盖(bundle 中没有指令)
Input.tsx: 8 statements, 6 covered (75%)
# 朴素合并(错误!)
Input.tsx: 10 statements, 14 covered (140%?!)
解决方案是在合并之前剥除 import 语句和指令,将所有源文件规范化到相同的基准。
为什么不使用 Vitest 的多项目设置?
Vitest 的 多项目设置 可以将单元测试和组件测试作为独立项目运行,并自动合并覆盖率。但不幸的是,一个 Vitest 中的 bug 阻止了正确的合并。在修复之前,需要使用外部工具。
选项 1:vitest-coverage-merge(仅 Vitest)
安装
npm install -D vitest-coverage-merge
合并命令
npx vitest-coverage-merge coverage/unit coverage/component -o coverage/merged
功能说明
- 从每个目录加载
coverage-final.json - 通过去除 ESM 导入语句和 React/Next.js 指令进行规范化
- 智能合并,优先保留浏览器测试结构
- 生成 HTML、LCOV 和 JSON 报告
该工具专门解决 jsdom 与真实浏览器测试之间的基于环境的覆盖率差异——不同于 Vitest 内置的 --merge-reports,后者仅处理分片测试运行。
更新的脚本(仅 Vitest)
{
"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"
}
}
选项 2:nextcov merge(包括 E2E)
要将 Vitest 覆盖率与 Playwright + nextcov 的 E2E 覆盖率合并,请使用 nextcov:
npx nextcov merge coverage/unit coverage/component coverage/e2e -o coverage/merged
它的作用
- 从每个目录加载
coverage-final.json - 默认剥离指令(
'use client'、'use server'、import 语句) - 为每个文件选择最佳结构(倾向于没有指令膨胀的源码)
- 使用 “max” 策略合并执行计数
- 生成 HTML、LCOV、JSON 和 text‑summary 报告
禁用指令剥离
如果你的源码没有指令不匹配:
npx nextcov merge coverage/unit coverage/component --no-strip
添加 E2E 覆盖率
Playwright 配置 (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": {
"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"
}
}
请参阅 Article 1 获取详细的 E2E 覆盖率设置。
合并策略细节
最大策略(默认)
对于每个覆盖项(语句、分支、函数),nextcov 会取所有来源中 最大 的计数:
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)
这种保守的做法反映了最高的实际覆盖率,而不会出现重复计数。
结构选择
在合并时,nextcov 会选择使用哪个来源的 结构(哪些语句存在,分支位于何处)。优先顺序:
- 没有 L1:0 指令语句的来源(E2E 风格的覆盖)
- 包含 更多覆盖项 的来源(分析更完整)
- 当上述条件相等时,后出现的来源(通常 E2E 是最后的)
这样可以防止因指令跟踪来源而导致的语句计数膨胀。
完整示例
目录结构
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
运行测试并合并
# 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
示例输出
📊 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/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
选择报告格式
默认情况下,nextcov 会生成所有格式。根据需要进行自定义:
# 仅生成 HTML 和 LCOV(用于 CI 集成)
npx nextcov merge coverage/unit coverage/e2e --reporters html,lcov
# 仅生成 JSON(用于后续处理)
npx nextcov merge coverage/unit coverage/e2e --reporters json
可用的报告器
| Reporter | 描述 |
|---|---|
html | 交互式 HTML 报告 |
lcov | 用于 CI 工具(Codecov 等)的 LCOV 格式 |
json | JSON 格式(coverage-final.json) |
text-summary | 控制台摘要 |
故障排除
“未找到 coverage‑final.json”
确保你的 Vitest 配置在 reporter 中包含 json:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'html'], // 必须包含 'json'
},
},
});
合并后覆盖率百分比异常
通常是指令剥离未按预期工作导致。请检查:
- 所有源码使用 V8 覆盖 (
provider: 'v8') - 源文件包含
'use client'或'use server'指令 - 使用
--no-strip合并,以查看原始数据
合并报告中缺少某些文件
只有当文件在至少一个来源中存在时才会出现。如果 E2E 测试从未触及某个文件,它仍会从单元/组件覆盖率中被包含。
摘要
| 步骤 | 命令 | 输出 |
|---|---|---|
| 单元测试 | npm run test:unit | coverage/unit/ |
| 组件测试 | npm run test:component | coverage/component/ |
| 端到端测试 | npm run e2e | coverage/e2e/ |
| 合并所有 | npx nextcov merge coverage/unit coverage/component coverage/e2e | coverage/merged/ |