blog

Next.js + Playwright E2E 커버리지 수집: nextcov 도입과 OOM 해결

Front-end / Testing#nextcov#Playwright#E2E#테스트 커버리지#V8#Node.js

1. nextcov란?

nextcov는 Next.js + Playwright 환경에서 E2E 테스트를 실행하는 동안 실제로 실행된 코드를 측정하는 도구다. 단위 테스트 커버리지와 달리, 브라우저에서 사용자 시나리오를 돌리며 어떤 코드 경로가 실행됐는지 본다.

Playwright 테스트 실행
  └─ CDP(Chrome DevTools Protocol)로 V8 JS 커버리지 수집
       └─ 소스맵으로 원본 TS 파일에 매핑 → Istanbul 형식 변환
            └─ HTML / LCOV / JSON 리포트
  • V8 커버리지: Chrome 엔진이 바이트 오프셋 단위로 기록한 실행 범위
  • finalize: 모은 V8 데이터를 소스맵으로 원본 코드에 매핑하고 합산하는 단계
항목빌드 계측(babel-plugin-istanbul 등)nextcov(V8 런타임 수집)
수집 방식빌드 시 코드에 카운터 삽입런타임에 엔진이 기록
빌드 설정 변경계측 플러그인 필요소스맵 포함 빌드만 필요
결과 매핑계측된 코드 기준소스맵으로 원본 소스 기준

2. 셋업

playwright.config.ts

import type { NextcovConfig } from 'nextcov';

const useCoverage = !!process.env.E2E_COVERAGE;

export const nextcov: NextcovConfig = {
  cdpPort: 9230,
  outputDir: 'coverage/e2e',
  sourceRoot: './src',
  include: ['src/**/*.{ts,tsx}'],
  exclude: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.spec.{ts,tsx}'],
  reporters: ['html', 'lcov', 'json', 'text-summary'],
};

export default defineConfig({
  webServer: {
    // 커버리지 모드는 소스맵을 포함한 프로덕션 빌드 서버로 실행
    command: useCoverage ? 'pnpm start:e2e' : 'pnpm dev',
    reuseExistingServer: !process.env.CI && !useCoverage,
  },
});

커버리지는 next build로 만든 프로덕션 빌드에서 수집했다. 개발 서버는 번들 구조와 소스맵이 프로덕션과 달라 매핑 결과가 맞지 않았다.

fixtures.ts: 테스트마다 브라우저 커버리지 저장

export const test = base.extend<{ coverage: undefined }>({
  coverage: [
    async ({ page }, use, testInfo) => {
      if (!process.env.E2E_COVERAGE) {
        await use(undefined);
        return;
      }

      await page.coverage.startJSCoverage({ resetOnNavigation: false });
      await use(undefined);
      const jsCoverage = await page.coverage.stopJSCoverage();

      // 앱 번들(_next/static/chunks)만 남김
      const appCoverage = jsCoverage.filter(({ url }) => url.includes('/_next/static/chunks/'));

      if (appCoverage.length > 0) {
        const testId = `${testInfo.workerIndex}-${testInfo.testId.replace(/[^a-zA-Z0-9]/g, '-')}`;
        await saveClientCoverageToDir(testId, appCoverage);
      }
    },
    { scope: 'test', auto: true },
  ],
});

auto: true라 모든 테스트에 자동 적용되고, E2E_COVERAGE가 없으면 바로 통과해서 일반 테스트 실행에는 영향이 없다. page.coverage는 Chromium에서만 지원된다.

finalize 스크립트와 package.json

// scripts/finalize-coverage.cjs
const { finalizeCoverage } = require('nextcov/playwright');

finalizeCoverage({
  cdpPort: 9230,
  outputDir: 'coverage/e2e',
  sourceRoot: './src',
  include: ['src/**/*.{ts,tsx}'],
  exclude: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.spec.{ts,tsx}'],
  reporters: ['html', 'lcov', 'json', 'text-summary'],
});
{
  "scripts": {
    "build:e2e": "cross-env E2E_COVERAGE=1 next build --webpack",
    "start:e2e": "cross-env NODE_ENV=production NODE_V8_COVERAGE=.v8-coverage node --inspect=9230 server.cjs",
    "test:e2e:coverage": "pnpm build:e2e && cross-env E2E_COVERAGE=1 playwright test && node scripts/finalize-coverage.cjs"
  }
}

서버는 --inspect=9230으로 띄워, nextcov가 같은 포트(cdpPort)로 서버 쪽 커버리지도 가져갈 수 있게 했다.

3. 데이터 흐름

테스트 실행 중
  ├─ startJSCoverage()
  ├─ 시나리오 실행 (click, fill, navigate ...)
  └─ stopJSCoverage() → coverage/e2e/.cache/client-{testId}.json

모든 테스트 종료 후
  └─ finalizeCoverage()
       ├─ .cache/*.json 전부 로드
       ├─ 소스맵으로 원본 파일에 매핑
       └─ 합산 → 리포트 출력

캐시 파일 하나가 테스트 하나의 커버리지 스냅샷이고, finalize가 이것을 모두 읽어 합친다.

4. OOM

증상

<--- Last few GCs --->
[...] Mark-Compact (reduce) ... MB  ← GC가 메모리를 회수하지 못하고 반복

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

finalize-coverage.cjs 실행 중 Node 프로세스가 힙 한도를 넘어 종료됐다.

원인

finalize는 모든 테스트의 캐시를 메모리에 올려 소스맵 매핑과 합산을 한다. 테스트마다 같은 번들 청크의 커버리지가 반복해서 들어 있으므로, 메모리 사용량은 대략 테스트 수 × 테스트당 수집된 청크 크기에 비례해 늘어난다. 테스트가 늘수록 급격히 커진다.

단기 해결 1: 힙 한도 늘리기

finalize를 실행하는 Node 프로세스에 옵션을 준다(빌드 스크립트가 아니라 finalize 실행에 붙여야 한다).

node --max-old-space-size=16384 scripts/finalize-coverage.cjs

간단하지만 많은 메모리가 필요하고, 테스트가 늘면 다시 부족해진다.

단기 해결 2: 처리 대상 줄이기

실행 코드가 없거나 커버리지를 볼 필요가 없는 파일은 exclude에 넣는다.

exclude: [
  'src/types/**',   // 타입 선언만 있는 파일
  'src/mocks/**',   // 테스트용 mock 데이터
  'src/styles/**',  // 스타일 상수
  'src/proxy.ts',   // 서버 전용 코드(브라우저 수집 대상 아님)
]

5. 구조적 해결: 그룹별 finalize 후 merge

테스트를 그룹으로 나눠 그룹마다 finalize하고, 마지막에 결과 JSON만 합친다. 메모리 사용량이 전체 테스트 수가 아니라 그룹 크기 기준이 된다.

common    → finalize (JSON만) ─┐
admin     → finalize (JSON만) ─┤→ nextcov merge → 최종 리포트
dashboard → finalize (JSON만) ─┤
mobile    → finalize (JSON만) ─┘

COVERAGE_GROUP 환경 변수

세 곳이 같은 그룹 경로를 바라봐야 한다.

// playwright.config.ts
const group = process.env.COVERAGE_GROUP;
export const nextcov: NextcovConfig = {
  outputDir: group ? `coverage/e2e/parts/${group}` : 'coverage/e2e',
  // ...
};
// fixtures.ts
const CLIENT_CACHE_DIR = process.env.COVERAGE_GROUP
  ? path.join('coverage/e2e/parts', process.env.COVERAGE_GROUP, '.cache')
  : 'coverage/e2e/.cache';
// finalize-coverage.cjs
const group = process.argv[2];
finalizeCoverage({
  outputDir: group ? `coverage/e2e/parts/${group}` : 'coverage/e2e',
  // 그룹 모드에서는 JSON만 만들고, 최종 리포트는 merge 단계에서 생성
  reporters: group ? ['json'] : ['html', 'lcov', 'json', 'text-summary'],
  // ...
});

실행 스크립트

pnpm build:e2e
node -e "require('fs').rmSync('coverage/e2e',{recursive:true,force:true})"

for group in common admin dashboard mobile; do
  cross-env E2E_COVERAGE=1 COVERAGE_GROUP=$group playwright test "e2e/$group"
  node --max-old-space-size=4096 scripts/finalize-coverage.cjs $group
done

npx nextcov merge \
  coverage/e2e/parts/common coverage/e2e/parts/admin \
  coverage/e2e/parts/dashboard coverage/e2e/parts/mobile \
  -o coverage/e2e --reporters html,lcov,json,text-summary
coverage/e2e/
  ├─ parts/
  │   ├─ common/   (.cache/, coverage-final.json)
  │   ├─ admin/
  │   ├─ dashboard/
  │   └─ mobile/
  ├─ coverage-final.json   ← merge 결과
  ├─ index.html
  └─ lcov.info

그룹 분할은 CI에서 그룹별로 병렬 job을 돌리고 마지막 job에서 merge하는 구조로도 자연스럽게 확장된다.