如何合并 Vitest 单元、组件和 E2E 测试覆盖率

发布: (2026年1月2日 GMT+8 04:19)
10 分钟阅读
原文: Dev.to

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 会选择使用哪个来源的 结构(哪些语句存在,分支位于何处)。优先顺序:

  1. 没有 L1:0 指令语句的来源(E2E 风格的覆盖)
  2. 包含 更多覆盖项 的来源(分析更完整)
  3. 当上述条件相等时,后出现的来源(通常 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 格式
jsonJSON 格式(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:unitcoverage/unit/
组件测试npm run test:componentcoverage/component/
端到端测试npm run e2ecoverage/e2e/
合并所有npx nextcov merge coverage/unit coverage/component coverage/e2ecoverage/merged/
Back to Blog

相关文章

阅读更多 »

RGB LED 支线任务 💡

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:我为何构建

介绍 大家好。今天我想分享一下我是谁、我在构建什么以及为什么。 早期职业生涯与倦怠 我在 17 年前开始我的 developer 生涯……