Vitest + Playwright로 유닛·E2E 계층 분리하기 — 설정부터 CI 통합까지
프로젝트가 어느 정도 커지면 꼭 한 번씩 맞닥뜨리는 순간이 있습니다. "테스트가 느린 건지, 테스트가 잘못된 건지, 아니면 둘 다인 건지" 모르겠는 그 순간이요. Jest로 유닛 테스트를 짜고, Cypress로 E2E를 돌리다 보면 설정 파일은 세 개고 CI는 20분을 넘기고, 어느 순간 팀 전체가 테스트를 점점 건드리지 않게 됩니다. 저도 그 경험을 했고, 정확히는 CI가 28분을 넘기던 날 오후에 "이건 뭔가 잘못됐다"고 결정했습니다. E2E를 유닛 테스트처럼 잔뜩 짜놨고, 설정 파일마다 Babel 트랜스파일 설정이 따로 있었고, 선택자는 CSS 클래스 기반이라 리팩터링할 때마다 줄줄이 깨졌습니다.
이 글은 Vite 기반 React 프로젝트를 주 대상으로 합니다. Next.js App Router나 Webpack 기반 환경은 추가 설정이 필요한 부분이 있는데, 그 지점은 단점 표에서 별도로 짚겠습니다. 핵심은 두 도구를 나란히 쓰는 것이 아니라, 테스트 계층을 명확히 나누어 각 계층에 적합한 도구를 배치하는 것입니다. 이 글을 읽고 나면 설정 파일 두 개로 유닛·컴포넌트·E2E 세 계층을 깔끔하게 분리하고, CI 파이프라인에 붙이는 것까지 한 흐름으로 진행할 수 있습니다.
핵심 개념
Vitest — Vite 생태계에서 태어난 테스트 러너
Vitest는 Vite 기반 프로젝트에서 별도의 설정 없이 Vite 설정을 그대로 공유하며 동작하는 테스트 프레임워크입니다. Jest와 호환되는 API(describe, it, expect, vi.fn())를 제공하기 때문에 기존 Jest 테스트 코드를 거의 그대로 옮겨올 수 있습니다.
속도 차이가 실제로 체감됩니다. Watch 모드에서 Jest 대비 5~28배 빠르다는 벤치마크가 있는데, 처음 써보면 "이게 맞나?" 싶을 정도로 결과가 빨리 뜹니다. 저도 처음에 뭔가 테스트를 빠뜨리는 건 아닌가 싶어서 한 번 더 확인했을 정도입니다. 2026년 초 기준 주간 다운로드 1,400만 건, State of JS 2025 만족도 96%로 테스트 도구 1위를 기록 중입니다.
ESM(ECMAScript Modules):
import/export문법을 브라우저나 Node.js가 네이티브로 처리하는 방식입니다. Jest는 CommonJS(require) 기반이라 ESM 코드를 쓰려면 Babel 변환이 필요했는데, Vitest는 네이티브 ESM을 그대로 처리합니다. Babel 설정 파일 하나가 줄어드는 것만으로도 유지 비용이 꽤 낮아집니다.
Playwright — E2E 테스트의 현재 표준
Microsoft가 개발한 E2E 테스트 도구로, Chromium·Firefox·WebKit 세 엔진을 모두 지원합니다. 브라우저와 직접 통신하기 때문에 동작이 안정적이고, 자동 대기(auto-wait) 덕분에 setTimeout 같은 임시 방편 없이도 비동기 UI를 자연스럽게 테스트할 수 있습니다. 실무에서 가장 크게 체감되는 부분이 이 auto-wait인데, "버튼이 나타날 때까지 기다려라"를 코드에 직접 쓰지 않아도 Playwright가 알아서 기다립니다.
Cypress와 자주 비교되는데, 결정적인 차이는 두 가지입니다. 무료 병렬 실행(Cypress는 유료 플랜 필요)과 멀티탭·팝업 지원입니다. 만족도 수치도 Playwright 91% vs Cypress 74%로 격차가 있습니다.
계층 분리가 핵심이다
두 도구를 같이 쓴다고 해서 "테스트를 많이 쓰는 것"이 목표가 아닙니다. 테스트 피라미드를 제대로 지키는 것이 목표입니다. 비율로 표현하면 유닛 70%, 통합 20%, E2E 10% 정도가 실무에서 흔히 권장되는 분배입니다.
| 계층 | 담당 도구 | 대상 | 속도 |
|---|---|---|---|
| 유닛·통합 테스트 | Vitest | 유틸리티, hooks, 컴포넌트, 비즈니스 로직 | 매우 빠름 (ms 단위) |
| 컴포넌트 브라우저 테스트 | Vitest Browser Mode | 실제 브라우저에서의 컴포넌트 렌더링 | 빠름 |
| E2E 테스트 | Playwright | 페이지 간 유저 플로우, 크로스브라우저 | 느림 (초 단위) |
Vitest Browser Mode: Vitest 4.0(2025년 12월 릴리즈)에서 stable로 승격된 기능으로, Playwright를 브라우저 자동화 프로바이더로 사용해 실제 Chromium 안에서 컴포넌트 테스트를 실행합니다. jsdom이 시뮬레이션하지 못하는 CSS 레이아웃, 실제 이벤트 동작 등을 검증할 때 유용합니다. 자신의 프로젝트에서 Vitest 4.x 이상을 사용하는지 먼저 확인해보시면 좋습니다.
정리하면 이런 구조입니다. Vitest가 빠른 피드백 루프를 담당하고, Playwright가 실제 사용자 플로우의 최종 확인을 담당합니다. 두 계층이 역할을 나눠 가지면 CI 전체가 훨씬 예측 가능해집니다.
실전 적용
설치 및 설정
먼저 의존성을 설치합니다. 저는 보통 역할별로 어떤 패키지가 들어오는지 파악하기 좋게 구분해서 씁니다.
# Vitest + 브라우저 모드 + React Testing Library
pnpm add -D vitest @vitest/browser playwright
pnpm add -D @testing-library/react @testing-library/user-event
# Playwright E2E
pnpm add -D @playwright/test
# 브라우저 바이너리 설치 (Playwright)
pnpm exec playwright install --with-deps@vitest/browser를 설치하면 playwright 프로바이더를 함께 사용할 수 있습니다. @vitest/browser-playwright라는 별도 패키지는 존재하지 않으니, 설치 시 주의하시면 좋습니다.
그다음 두 설정 파일을 루트에 만들어 둡니다.
// vitest.config.ts
/// <reference types="@vitest/browser/providers/playwright" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
// src/** 아래만 Vitest가 담당 — e2e/** 는 Playwright 전용
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['e2e/**'],
browser: {
enabled: false, // 컴포넌트를 실제 브라우저에서 테스트하려면 true로 전환
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
},
},
})// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})설정 파일에서 핵심적인 부분을 짚어보면 이렇습니다.
| 설정 항목 | 의미 |
|---|---|
exclude: ['e2e/**'] |
Vitest가 Playwright E2E 파일을 잡아가지 않도록 분리 |
retries: process.env.CI ? 2 : 0 |
CI에서만 재시도 허용, 로컬에서는 실패를 바로 확인 |
reuseExistingServer: !process.env.CI |
로컬에 dev 서버가 이미 켜져 있으면 재사용 |
trace: 'on-first-retry' |
첫 재시도 때만 trace 수집, 디스크 용량 절약 |
계층별 테스트 코드 작성
유닛 테스트는 Vitest로 빠르게, E2E는 Playwright로 핵심 플로우만 커버하는 패턴입니다. 저는 이 세 파일 구조를 템플릿처럼 새 프로젝트에 그대로 복사해서 씁니다.
// src/utils/price.test.ts — Vitest 유닛 테스트
import { describe, it, expect } from 'vitest'
import { formatPrice } from './price'
describe('formatPrice', () => {
it('천 단위 쉼표를 적용한다', () => {
expect(formatPrice(10000)).toBe('10,000원')
})
it('0원은 그대로 반환한다', () => {
expect(formatPrice(0)).toBe('0원')
})
})// src/components/CartButton.test.tsx — Vitest 컴포넌트 테스트
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CartButton } from './CartButton'
it('클릭 시 장바구니에 아이템이 추가된다', async () => {
const onAdd = vi.fn()
render(<CartButton onAdd={onAdd} />)
await userEvent.click(screen.getByRole('button', { name: '장바구니 추가' }))
expect(onAdd).toHaveBeenCalledOnce()
})vi는 반드시 vitest에서 명시적으로 import해야 합니다. globals: true 설정을 쓰면 생략할 수 있지만, 저는 어디서 왔는지가 명확하게 보이는 쪽을 선호합니다.
// e2e/checkout.spec.ts — Playwright E2E 테스트
import { test, expect } from '@playwright/test'
test('결제 플로우 전체 검증', async ({ page }) => {
await page.goto('/products/1')
await page.getByRole('button', { name: '장바구니 추가' }).click()
await page.goto('/cart')
await page.getByRole('button', { name: '결제하기' }).click()
await expect(page).toHaveURL('/checkout')
})Playwright 테스트에서 getByRole을 쓰는 이유가 있습니다. CSS 클래스나 XPath 기반 선택자는 UI 리팩터링 한 번에 줄줄이 깨지는데, ARIA role 기반 선택자는 실제 사용자가 해당 요소를 어떻게 인식하는지 기준으로 동작해서 훨씬 안정적입니다. 실제로 저희 팀에서 버튼 컴포넌트를 교체했을 때, getByRole 기반 테스트는 하나도 깨지지 않았습니다.
한 가지 더 — 로그인이 필요한 플로우를 E2E로 테스트할 때는 매 테스트마다 로그인을 반복하지 않아도 됩니다. Playwright의 storageState를 사용하면 인증 세션을 한 번 저장해두고 재사용할 수 있습니다. 단점 표의 storageState 항목에서 간략히 설명했으니 함께 참고하시면 좋습니다.
package.json 스크립트와 CI 통합
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "pnpm test:coverage && pnpm test:e2e"
}
}아래는 GitHub Actions 핵심 스텝 발췌입니다. checkout, pnpm install, 캐시 설정은 각 프로젝트 환경에 맞게 앞에 추가하시면 됩니다.
# .github/workflows/test.yml (핵심 스텝 발췌)
- name: Run Unit Tests
run: pnpm test:coverage
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run E2E Tests
run: pnpm test:e2eplaywright install --with-deps를 별도 스텝으로 분리해두는 것을 권장합니다. 브라우저 바이너리 캐시와 node_modules 캐시를 독립적으로 관리할 수 있어서 파이프라인 실행 시간을 줄이는 데 도움이 됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| Vitest 속도 | Jest 대비 Watch 모드 5~28배 빠름, 코드 변경 후 즉시 피드백 |
| 설정 단순화 | Vite 설정을 그대로 공유, 별도 Babel 설정 불필요 |
| Jest 마이그레이션 | API 호환으로 기존 Jest 테스트 대부분 그대로 이식 가능 |
| Playwright 병렬 실행 | 무료로 멀티 워커 지원, CI 시간 단축 |
| 크로스브라우저 커버리지 | Chromium·Firefox·WebKit 단일 설정으로 검증 |
| 자동 대기 | 비동기 UI 처리에 별도 대기 코드 없이 안정적 동작 |
| 인증 세션 재사용 | storageState로 로그인 반복 제거, E2E 속도 개선 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| E2E 실행 속도 | 유닛 테스트의 10~50배 느림 | 핵심 플로우 20~30개로 제한, 나머지는 Vitest로 커버 |
| 브라우저 바이너리 | CI 디스크 사용량 증가 (~300MB) | GitHub Actions 캐시 적극 활용 |
| Vite 미사용 환경 | Next.js App Router 일부, Webpack 기반 레거시에서 추가 설정 필요 | vitest.config.ts에서 환경 별도 지정 |
| E2E 유지 비용 | 과도한 E2E 테스트는 유지 비용 급증 | 테스트 피라미드 원칙 준수, 비즈니스 크리티컬한 플로우에만 집중 |
| Flaky 테스트 | 간헐적 실패로 CI 신뢰도 저하 | retries: 2 설정 + 선택자 안정화로 흡수 |
Flaky 테스트: 코드 변경 없이도 가끔 성공하고 가끔 실패하는 테스트입니다. 주로 타이밍 이슈나 불안정한 선택자에서 발생하며, 팀 전체의 CI 신뢰도를 갉아먹는 주범입니다.
자주 하는 실수 세 가지
-
E2E 테스트를 유닛 테스트처럼 많이 짜는 것. 유틸리티 함수 하나하나를 Playwright로 검증하면 CI가 30분을 넘깁니다. 저희 팀도 초반에 이 패턴을 반복했고, E2E가 CI에서 40분을 넘겼습니다. 비즈니스에 직접 타격을 주는 플로우(결제, 회원가입, 핵심 CRUD)만 E2E로 커버하고 나머지는 Vitest로 올리는 분배가 핵심입니다.
-
CSS 클래스나 XPath로 Playwright 선택자를 잡는 것.
div.checkout-btn같은 선택자는 UI 컴포넌트를 리팩터링할 때마다 E2E 전체가 깨집니다.getByRole,getByText,data-testid기반으로 작성하면 구조 변경에 훨씬 유연하게 대응할 수 있습니다. -
Vitest와 Playwright 테스트 파일을 구분 없이 섞는 것.
include/exclude설정을 제대로 하지 않으면 Vitest가 Playwright 파일을 자기 테스트로 잡아가려 해서 이상한 오류가 납니다.src/**는 Vitest,e2e/**는 Playwright로 디렉토리부터 명확히 분리해두는 것을 권장합니다.
마치며
Vitest + Playwright 조합의 핵심은 도구를 많이 쓰는 것이 아니라, 각 계층에 맞는 도구를 배치해서 "빠른 피드백"과 "신뢰할 수 있는 최종 검증"을 동시에 확보하는 것입니다. 이 구조가 자리를 잡으면 팀원이 PR을 올릴 때마다 "CI 돌아가는 거 맞겠지?"라는 불안이 아니라, "유닛은 30초, E2E는 5분" 같은 예측 가능한 숫자를 기준으로 판단할 수 있게 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 프로젝트에 Vitest를 먼저 도입해볼 수 있습니다.
pnpm add -D vitest를 설치하고,vitest.config.ts를 기존vite.config.ts와 같은 디렉토리에 만들어보세요. Jest 기반 테스트가 있다면import { describe, it, expect } from 'vitest'로 교체하는 것만으로도 대부분 그대로 동작합니다. -
E2E 대상 플로우를 먼저 목록화해보시면 좋습니다. 서비스에서 "이게 안 되면 진짜 장애"인 유저 플로우를 20~30개 뽑아
e2e/디렉토리 아래 spec 파일로 구조만 잡아두면, Playwright 도입의 절반은 끝난 셈입니다. -
CI 파이프라인에 순서대로 붙이는 방식을 권장합니다.
pnpm test:coverage로 Vitest 커버리지를 먼저 돌리고, 통과하면playwright install --with-deps후pnpm test:e2e를 이어서 실행하는 두 스텝 구조가 가장 일반적입니다. 처음엔 chromium 하나만 활성화하고, 안정화되면 firefox·webkit을 추가해가는 방식으로 점진적으로 넓혀가시면 됩니다.
참고 자료
- Vitest 공식 문서 | vitest.dev — 설정 옵션 전체를 빠르게 훑을 때 가장 먼저 열어보는 곳
- Vitest Browser Mode 가이드 | vitest.dev — Browser Mode 설정 시 이 페이지가 핵심, 프로바이더 옵션이 상세히 정리되어 있음
- Playwright 공식 문서 | playwright.dev — API 레퍼런스가 특히 잘 정리되어 있어 선택자 문법 찾을 때 유용
- Playwright Best Practices | playwright.dev —
getByRole우선 원칙 등 공식 권장 패턴이 압축되어 있는 페이지 - Why Playwright + Vitest is the Future of Web Testing | DEV Community — 두 도구의 조합이 왜 자연스러운지 맥락을 잡는 데 도움이 됐음
- Vitest vs Playwright | BrowserStack — 두 도구의 역할 차이를 표로 비교한 글, 팀에 소개할 때 참고 자료로 유용
- Vitest Browser Mode vs Playwright | Epic Web Dev — 컴포넌트 테스트를 Browser Mode로 할지 E2E로 할지 고민될 때 읽어볼 만한 글
- Vitest 4 Browser Mode Stable 릴리즈 | InfoQ — Vitest 4.0 릴리즈 내용 요약, 어떤 변경이 있었는지 빠르게 파악 가능
- React Component Testing with Vitest Browser Mode and Playwright | akoskm.com — 실제 React 프로젝트에 적용한 사례, 설정 예시가 구체적
- Configure Vitest, MSW and Playwright in a React+Vite+TS Project | DEV Community — MSW까지 함께 엮는 전체 설정을 보고 싶을 때 참고
- 15 Best Practices for Playwright Testing 2026 | BrowserStack — 선택자 전략부터 CI 최적화까지, Playwright 실무 패턴이 잘 정리된 레퍼런스