Vitest 단위, 컴포넌트 및 E2E 테스트 커버리지를 병합하는 방법

발행: (2026년 1월 2일 오전 05:19 GMT+9)
17 분 소요
원문: Dev.to

Source: Dev.to

Vitest 단위·컴포넌트·E2E 테스트 커버리지를 하나로 합치는 방법

프로젝트에 Vitest를 사용해 단위 테스트와 컴포넌트 테스트를 작성하고, Playwright(또는 Cypress) 같은 도구로 E2E 테스트를 수행하고 있나요?
각 테스트 러너는 자체적인 커버리지 리포트를 생성하지만, 전체 프로젝트의 커버리지를 한눈에 보기 위해서는 이 리포트들을 병합해야 합니다.

이 글에서는 다음을 다룹니다.

  1. Vitest와 Playwright 각각에서 커버리지 파일을 생성하는 방법
  2. c8(또는 nyc)를 이용해 JSON 커버리지 파일을 병합하는 방법
  3. 병합된 결과를 cobertura, lcov, html 등 원하는 포맷으로 출력하는 방법

1️⃣ 프로젝트 설정

1.1 package.json에 필요한 의존성 추가

npm i -D vitest @vitest/coverage-istanbul c8
# Playwright를 E2E 테스트에 사용한다면
npm i -D @playwright/test

1.2 Vitest 설정 (vite.config.ts)

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import istanbul from 'vite-plugin-istanbul';

export default defineConfig({
  plugins: [
    vue(),
    istanbul({
      // 테스트 실행 시 자동으로 커버리지 파일을 생성
      include: 'src/**/*.ts',
      exclude: ['node_modules', 'test'],
      extension: [ '.js', '.ts', '.vue' ],
      requireEnv: false,
    }),
  ],
  test: {
    // Vitest가 커버리지를 수집하도록 설정
    coverage: {
      provider: 'istanbul',
      reporter: ['json', 'html'],
    },
  },
});

핵심: vite-plugin-istanbul은 Vitest가 실행될 때 coverage-final.json 파일을 ./coverage 폴더에 생성합니다.

1.3 Playwright 설정 (playwright.config.ts)

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  reporter: [['list']],
  use: {
    // 브라우저 환경에서 커버리지를 수집하도록 설정
    launchOptions: {
      // Chrome/Chromium 기반 브라우저에만 적용
      args: ['--js-flags=--experimental-modules'],
    },
    // Playwright가 자동으로 커버리지 파일을 저장하도록 함
    coverage: {
      enabled: true,
      // 커버리지 파일이 저장될 디렉터리
      outputFolder: 'coverage/e2e',
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

주의: Playwright 1.38+부터 coverage 옵션이 기본 제공됩니다. 구버전이라면 c8을 직접 래핑해야 합니다.


2️⃣ 각각의 테스트 실행 및 커버리지 파일 생성

# ① 단위·컴포넌트 테스트 (Vitest)
npm run test:unit   # => ./coverage/unit/coverage-final.json

# ② E2E 테스트 (Playwright)
npm run test:e2e    # => ./coverage/e2e/coverage-final.json

Tip: npm run test:unit 스크립트 예시

"scripts": {
  "test:unit": "vitest run --coverage"
}

3️⃣ 커버리지 파일 병합

3.1 c8 사용 (추천)

# 모든 JSON 커버리지 파일을 하나로 합침
npx c8 merge ./coverage/**/coverage-final.json -o ./coverage/merged/coverage.json

3.2 nyc 사용 (대안)

# nyc가 제공하는 merge 명령
npx nyc merge ./coverage/**/coverage-final.json ./coverage/merged/coverage.json

핵심: ./coverage/**/coverage-final.jsonunit, component, e2e 각각이 만든 파일을 모두 포함합니다.


4️⃣ 병합된 커버리지 리포트 생성

# HTML 리포트
npx c8 report --reporter=html --temp-dir=./coverage/merged

# lcov (CI에 업로드할 때 유용)
npx c8 report --reporter=lcov --temp-dir=./coverage/merged

# cobertura (SonarQube 등)
npx c8 report --reporter=cobertura --temp-dir=./coverage/merged

생성된 리포트는 ./coverage/merged 디렉터리 아래에 위치합니다. index.html을 열면 전체 프로젝트에 대한 커버리지를 한눈에 확인할 수 있습니다.


5️⃣ CI 파이프라인에 적용하기

GitHub Actions 예시:

name: Test & Coverage

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install dependencies
        run: npm ci

      - name: Run unit & component tests
        run: npm run test:unit

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Merge coverage
        run: npx c8 merge ./coverage/**/coverage-final.json -o ./coverage/merged/coverage.json

      - name: Generate lcov report
        run: npx c8 report --reporter=lcov --temp-dir=./coverage/merged

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/merged/lcov.info

위와 같이 설정하면 PR마다 전체 커버리지가 자동으로 계산되고, Codecov(또는 Coveralls)와 같은 서비스에 업로드됩니다.


🎉 마무리

  • VitestPlaywright(또는 다른 E2E 프레임워크) 각각에서 커버리지를 수집한다.
  • c8(또는 nyc)을 이용해 JSON 파일을 병합한다.
  • 병합된 데이터를 기반으로 HTML, lcov, cobertura 등 원하는 포맷의 리포트를 만든다.

이 과정을 자동화하면 팀 전체가 동일한 커버리지 기준을 공유할 수 있어, 테스트 품질을 지속적으로 향상시킬 수 있습니다.

궁금한 점이 있으면 언제든 댓글로 알려 주세요! 🚀

현대 React 애플리케이션은 종종 다양한 환경에서 여러 종류의 테스트를 실행합니다. 각 테스트는 자체 커버리지 보고서를 생성합니다. 이 기사에서는 이를 단일하고 정확한 커버리지 보고서로 병합하는 방법을 보여줍니다.

기술 스택

계층기술
애플리케이션React + Next.js / Vite / Webpack
단위 테스트Vitest (jsdom environment)
컴포넌트 테스트Vitest Browser Mode + Playwright
E2E 테스트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']coverage-final.json을 생성하려면 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"
  }
}

Directive 스트리핑 문제

V8 커버리지 카운트가 환경마다 다릅니다:

환경실행 가능한 것으로 간주되는 항목
Vitest 유닛 테스트 (jsdom)import 문과 디렉티브 ('use client', 'use server')
Vitest 브라우저 테스트유닛 테스트와 동일
E2E 커버리지 (번들링)번들링 과정에서 디렉티브와 import가 제거됨

정규화 없이

# Vitest 유닛 커버리지 (디렉티브 포함)
Input.tsx: 10 statements, 8 covered (80%)

# E2E 커버리지 (번들에 디렉티브 없음)
Input.tsx: 8 statements, 6 covered (75%)

# 순진한 병합 (잘못된 결과!)
Input.tsx: 10 statements, 14 covered (140%?!)

해결책은 병합하기 전에 import 문과 디렉티브를 제거하여 모든 소스를 동일한 기준선으로 정규화하는 것입니다.

왜 Vitest의 멀티‑프로젝트 설정을 사용하지 않을까?

Vitest의 멀티‑프로젝트 설정은 단위 테스트와 컴포넌트 테스트를 별개의 프로젝트로 실행하고 커버리지를 자동으로 병합할 수 있습니다. 안타깝게도, Vitest의 버그 때문에 올바른 병합이 이루어지지 않습니다. 이 문제가 해결될 때까지는 외부 도구가 필요합니다.

옵션 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 import 문과 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, 텍스트‑요약 보고서를 생성합니다

지시문 제거 비활성화

소스에 지시문 불일치가 없을 경우:

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"
  }
}

자세한 E2E 커버리지 설정은 Article 1을 참고하세요.

병합 전략 세부 정보

최대 전략 (기본)

각 커버리지 항목(문, 분기, 함수)에 대해 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 )
===============================================================================

✅ 병합된 커버리지 보고서가 생성되었습니다
   Output: coverage/merged

보고서 형식 선택

기본적으로 nextcov는 모든 형식을 생성합니다. 필요에 따라 맞춤 설정하세요:

# 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

사용 가능한 리포터

ReporterDescription
html인터랙티브 HTML 보고서
lcovCI 도구용 LCOV 형식 (Codecov 등)
jsonJSON 형식 (coverage-final.json)
text-summary콘솔 요약

문제 해결

“No coverage‑final.json 찾을 수 없음”

Vitest 설정에 json이 리포터에 포함되어 있는지 확인하세요:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      reporter: ['text', 'json', 'html'], // Must include 'json'
    },
  },
});

병합 후 커버리지 비율이 이상하게 보임

일반적으로 지시문 스트리핑이 예상대로 작동하지 않아 발생합니다. 확인하세요:

  • 모든 소스가 V8 커버리지를 사용하고 있는지 (provider: 'v8')
  • 소스 파일에 'use client' 또는 'use server' 지시문이 포함되어 있는지
  • --no-strip 옵션으로 병합하여 원시 데이터를 확인해 보세요

병합된 보고서에 일부 파일이 누락됨

파일은 최소 하나의 소스에 존재할 때만 표시됩니다. E2E 테스트가 파일을 전혀 다루지 않더라도 유닛/컴포넌트 커버리지에서 포함됩니다.

요약

단계명령출력
단위 테스트npm run test:unitcoverage/unit/
컴포넌트 테스트npm run test:componentcoverage/component/
E2E 테스트npm run e2ecoverage/e2e/
모두 병합npx nextcov merge coverage/unit coverage/component coverage/e2ecoverage/merged/
Back to Blog

관련 글

더 보기 »

RGB LED 사이드퀘스트 💡

markdown !Jennifer Davis https://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년 동안 경력을 시작했습니다.