Next.js + Playwright E2E 커버리지 수집: nextcov 도입과 OOM 해결
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하는 구조로도 자연스럽게 확장된다.