테스팅 강좌 #6 Playwright로 E2E와 CI 통합 — 트랙 마무리
테스트 트랙의 마지막 글입니다. #1~5 가 한 컴포넌트 / 한 모듈 안을 다뤘다면, 이번 글은 진짜 브라우저에서 실제 사용자 시나리오를 끝까지 따라가는 E2E.
테스팅 강좌에서 이번 글의 위치:
- #1 왜 테스트인가
- #2 Vitest 셋업과 첫 단위 테스트
- #3 React Testing Library
- #4 비동기와 네트워크 모킹 — MSW
- #5 사용자 이벤트와 폼 테스트
- #6 Playwright로 E2E와 CI 통합 — 트랙 마무리 ← 이번 글
이번 글은 도구는 Playwright, 마지막 절에서 트랙 6편 전체를 회수합니다.
E2E가 잡는 것, 못 잡는 것 #
#1 에서 짚은 결을 한 번 더. E2E의 위치는:
┌──────────────┐
│ E2E │ ← 핵심 시나리오 5~10개
├──────────────┤
│ 통합 (RTL) │
├──────────────┤
│ 단위 │
├──────────────┤
│ 정적 │
└──────────────┘E2E가 잡는 것:
- 컴포넌트 통합 테스트가 못 보는 것 — 라우팅, 인증 흐름, 페이지 간 상태 전달.
- 진짜 브라우저 차이 — Safari가 Chrome과 다르게 동작하는 잔주름.
- 백엔드와 프런트엔드의 계약이 어긋나는 부분입니다.
- 진짜 네트워크 / 진짜 DB가 도는 환경에서만 보이는 race / timing.
E2E가 못 잡거나 가성비가 안 좋은 것:
- 컴포넌트 한 개의 동작 — RTL이 더 빠르고 정확.
- edge case의 모든 분기 — E2E 30개로 잡을 분기를 단위 5개로 잡을 수 있음.
- 서버의 비즈니스 로직 — 서버 사이드 단위/통합 테스트가 답.
권장 갯수 — 핵심 사용자 흐름 5~10개. “회원가입 → 로그인 → 주요 기능 한두 개 → 결제” 정도. 그 이상으로 늘리면 유지보수가 빠르게 부담이 됩니다.
Playwright vs Cypress — 짧게 #
E2E 도구는 둘이 양강. 결만 짚으면:
| Playwright | Cypress | |
|---|---|---|
| 브라우저 | Chromium, Firefox, WebKit (Safari) | Chromium, Firefox, WebKit (실험적) |
| 실행 모델 | Out-of-process (브라우저 자동화) | In-process (브라우저 안에서) |
| 다중 탭 / iframe | 자연스럽게 지원 | 까다로움 |
| 병렬 실행 | 기본 지원 | 유료 (Cypress Cloud) |
| 모바일 에뮬레이션 | 강함 | 보통 |
| 학습 곡선 | 낮음 | 가장 낮음 |
| 문서 | 좋음 | 매우 좋음 |
새 프로젝트에서 안 고를 이유가 거의 없는 게 Playwright가 됐습니다. WebKit 지원, 병렬 실행 무료, MS가 활발히 개발 중. Cypress가 강한 영역은 가르치기 쉬운 DX와 시각적 디버거 정도.
이 글은 Playwright 기준.
셋업 #
가장 빠른 시작 — 공식 init 명령.
pnpm create playwright물어보는 것:
- TypeScript? → 예
- 테스트 디렉터리? →
e2e(또는tests) - GitHub Actions 워크플로우 추가? → 예 (CI 절에서 더 다룸)
- 브라우저 설치? → 예 (Chromium / Firefox / WebKit 다운로드, 약 500MB)
생성된 playwright.config.ts 일부:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
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:5173',
reuseExistingServer: !process.env.CI,
},
});핵심 옵션:
baseURL— 모든page.goto('/')의 기준. 환경마다 다른 URL을 환경변수로 갈아끼움.webServer— 테스트 시작 전 자동으로 dev 서버를 띄워줌. CI 에서도 똑같이 동작.projects— 같은 테스트를 여러 브라우저에서. CI에서 셋 다 돌리는 게 정석.trace: 'on-first-retry'— 처음 실패한 테스트만 trace 기록 (스크린샷 + DOM 스냅샷 + 네트워크). 성공 케이스는 기록 안 해서 결과가 가벼움.retries— flaky 한 테스트 자동 재시도. CI 에서만.
첫 E2E #
가장 단순한 테스트.
import { test, expect } from '@playwright/test';
test('홈 페이지가 제목을 띄운다', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/My App/);
});
test('로그인 링크를 클릭하면 로그인 페이지로 이동한다', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: '로그인' }).click();
await expect(page).toHaveURL('/login');
});{ page }가 매 테스트마다 새 브라우저 컨텍스트. 깨끗한 쿠키/스토리지로 시작합니다.
queries는 RTL과 거의 같은 모양 — getByRole, getByLabel, getByText. 같은 철학을 공유합니다.
pnpm playwright test # headless
pnpm playwright test --headed # 브라우저 띄워서
pnpm playwright test --ui # 시각적 UI 모드 (강력)
pnpm playwright test --debug # 디버그 모드 (한 단계씩)--ui 모드를 한 번 띄워보세요. 좌측에 테스트 목록, 가운데에 브라우저 화면, 우측에 액션 타임라인. 디버깅 경험이 완전히 달라집니다.
Locator — 단순한 element가 아니다 #
Playwright의 getByRole(...) 등은 사실 Locator 객체를 돌려줍니다. RTL의 결과는 즉시 element 인데, Playwright는 lazy — 실제로 액션이 발생할 때까지 element를 찾지 않습니다.
const button = page.getByRole('button', { name: '저장' });
// 여기서는 아직 DOM 을 안 봄
await button.click();
// 이 시점에 element 를 찾고, 못 찾으면 자동 대기
이 구조 덕분에 **자동 대기(auto-waiting)**가 자연스러워요. RTL처럼 waitFor / findBy를 쓸 필요가 거의 없습니다 — await 자체가 element를 기다리니까.
await page.getByRole('button', { name: '저장' }).click();
// 버튼이 나타나기를 기다리고, enabled 가 되기를 기다리고, 클릭
await expect(page.getByRole('alert')).toHaveText('저장됨');
// alert 가 나타나기를 기다리고, 텍스트가 일치할 때까지
expect도 같은 결. **await expect(locator).toBeVisible()**이 5초간 (기본) 폴링하면서 기다리고, 끝까지 안 되면 그제야 실패.
로그인 흐름 — 진짜 E2E #
좀 더 진지한 시나리오. 로그인 → 대시보드 → 로그아웃.
import { test, expect } from '@playwright/test';
test.describe('인증 흐름', () => {
test('잘못된 비밀번호로 로그인하면 에러가 보인다', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('user@example.com');
await page.getByLabel('비밀번호').fill('wrong');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByRole('alert')).toContainText('비밀번호');
await expect(page).toHaveURL('/login'); // 페이지 이동 X
});
test('올바른 자격증명으로 로그인하면 대시보드로 이동한다', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('user@example.com');
await page.getByLabel('비밀번호').fill('correct-password');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: /환영합니다/ })).toBeVisible();
});
test('대시보드에서 로그아웃하면 홈으로 돌아간다', async ({ page }) => {
// 사전 로그인
await page.goto('/login');
await page.getByLabel('이메일').fill('user@example.com');
await page.getByLabel('비밀번호').fill('correct-password');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
// 로그아웃
await page.getByRole('button', { name: '로그아웃' }).click();
await expect(page).toHaveURL('/');
});
});세 번째 테스트가 흥미로워요 — 매번 사전 로그인을 반복해야 함. E2E가 곧장 부담스러워지는 부분입니다. 다음 절에서 풉니다.
storageState — 로그인 상태 공유
#
매 테스트가 로그인 흐름을 다시 도는 건 비효율. Playwright의 storageState가 이 문제를 풉니다.
import { test as setup } from '@playwright/test';
const authFile = '.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('user@example.com');
await page.getByLabel('비밀번호').fill('correct-password');
await page.getByRole('button', { name: '로그인' }).click();
await page.waitForURL('/dashboard');
// 쿠키와 localStorage 를 파일로 저장
await page.context().storageState({ path: authFile });
});projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],이제 모든 chromium 테스트가 이미 로그인된 상태로 시작합니다. setup은 한 번만 돌고, 본 테스트들이 그 결과를 공유.
Network 모킹 — Playwright도 가능 #
E2E 라고 항상 진짜 백엔드를 쓸 필요는 없습니다. 일부 시나리오(에러 응답, 느린 응답)는 모킹이 자연스러움.
test('서버가 500 을 주면 에러 화면을 띄운다', async ({ page }) => {
await page.route('/api/posts', (route) => {
route.fulfill({ status: 500, body: 'Internal Error' });
});
await page.goto('/posts');
await expect(page.getByRole('alert')).toContainText('서버 오류');
});page.route(pattern, handler)가 #4 의 MSW와 비슷한 역할입니다. 하지만 E2E의 본 가치는 진짜 백엔드 검증에 있습니다. 모킹은 평소엔 안 쓰고 — error path / edge case 한두 개 정도만.
Page Object Pattern — 가볍게 #
테스트가 늘어나면 같은 selector를 매 테스트에서 반복하게 됩니다. Page Object 패턴이 이를 정리합니다.
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly alert: Locator;
constructor(public readonly page: Page) {
this.emailInput = page.getByLabel('이메일');
this.passwordInput = page.getByLabel('비밀번호');
this.submitButton = page.getByRole('button', { name: '로그인' });
this.alert = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('로그인 흐름', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'correct-password');
await expect(page).toHaveURL('/dashboard');
});selector가 한곳에 모이고, 변경 시 한 곳만 수정. 한 가지 주의 — 너무 많은 추상화는 오히려 가독성을 해쳐요. selector와 자주 쓰는 액션 까지만 PO에. 검증(expect) 은 테스트 안에 두는 게 보통 깔끔합니다.
CI 통합 — GitHub Actions #
pnpm create playwright가 자동으로 만들어 주는 워크플로우.
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps
- name: Run tests
run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30핵심 포인트:
playwright install --with-deps— 브라우저 + 시스템 의존성을 함께 설치. CI 마다 캐시되는 부분이라 캐시 액션을 추가하면 더 빨라짐.upload-artifactwithif: always()— 테스트가 깨졌을 때 HTML 리포트를 산출물로 업로드. 깨진 테스트의 trace / screenshot을 PR에서 다운받아 볼 수 있음.
Vitest도 같이 — 한 워크플로우에 둘 다 #
E2E와 단위/통합을 한 워크플로우에 둘 다.
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm test:run --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm exec playwright install --with-deps
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/병렬로 돌면 unit (수십 초) 와 e2e (수 분) 가 동시에 — PR 피드백이 짧음.
커버리지 — 어디까지 보여줄까 #
Vitest의 커버리지 리포트가 CI에 올라오면, GitHub PR에 코멘트로 자동 표시되는 액션이 흔히 쓰여요.
- uses: davelosert/vitest-coverage-report-action@v2
if: github.event_name == 'pull_request'PR에 “이 PR이 커버리지를 X% → Y% 변경” 같은 코멘트가 자동으로 달립니다.
#1, #2 에서 한 번씩 짚은 결 — 숫자에 매달리지 말 것. 커버리지가 줄었다고 자동 머지 차단 같은 룰은 보통 자기 발등 찍기. 핵심 흐름이 잡혔는지가 더 중요합니다.
Visual regression — 한 줄 #
화면이 의도와 다르게 바뀌었는지 잡고 싶다면 — Playwright의 toHaveScreenshot().
test('홈 페이지의 시각적 회귀', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png');
});처음 실행 시 baseline 스크린샷이 저장되고, 이후 실행에서 차이가 있으면 실패. CI의 OS / 폰트 차이가 false positive를 자주 일으켜서, 보통 같은 OS에서 baseline을 만들고 그것만 비교합니다.
규모가 작은 프로젝트에서는 굳이 안 도입해도 OK. 크면 Percy / Chromatic 같은 SaaS가 더 깔끔합니다.
흔한 함정 #
flaky 테스트가 너무 많음 — 보통 setTimeout / network race / animation이 원인. Playwright의 expect(...).toBe...()가 자동 대기를 하니, page.waitForTimeout(1000) 같은 fixed sleep은 거의 항상 안티패턴.
테스트는 통과인데 cookies / storage가 새는 듯 — storageState가 잘못 잡혀 있거나, test.use({ storageState: ... })와 dependencies의 짝이 어긋난 경우.
CI 에서만 깨짐 — 보통 timing / 화면 크기 / fonts. playwright.config.ts의 viewport와 baseURL이 CI와 로컬에서 같은지 확인.
.toHaveText()가 텍스트 일부에서 실패 — toContainText()와 헷갈림. 정확한 매치는 toHaveText, 부분은 toContainText.
병렬 실행이 데이터 충돌 — 같은 테스트 user가 동시에 돌면 데이터가 충돌합니다. 테스트마다 다른 데이터(이메일에 timestamp 추가 등) 또는 test.describe.configure({ mode: 'serial' })로 직렬화.
WebKit만 깨짐 — Safari의 미묘한 호환 차이. playwright.config.ts에서 일시적으로 projects에서 webkit 제외하고 따로 디버깅. 보통은 CSS / transform / focus 관련.
트랙 6편의 회수 #
#1 의 그림에서 시작해 — 정적 → 통합 → 단위 → E2E 의 분배를 따라왔습니다. 6편이 닿은 곳:
#1 — 그림 (피라미드 vs 트로피)
#2 — Vitest (단위 + 그 셋업)
#3 — RTL (통합의 첫 걸음)
#4 — MSW (네트워크 가로채기 = 통합의 핵심)
#5 — userEvent + 폼 (사용자 입력 통합)
#6 — Playwright + CI (E2E + 자동화)그리고 처음에 짚었던 한 문장으로 돌아오면:
테스트가 안 되는 이유는 보통 “바빠서” 가 아니라 무엇/어디서/어떻게 의 그림이 없어서.
이제 그림이 잡혀 있습니다. 작은 함수에 단위 테스트를 짤지, 컴포넌트 통합으로 갈지, E2E 시나리오에 넣을지 — 결정 가이드가 손에 있습니다. 여기서 더 갈 곳:
- 상태관리 심화 트랙 — TanStack Query / Zustand 같은 도구의 테스트 패턴.
- 백엔드 테스트 — 파이썬 트랙의
pytest, 장고 중급 #7 테스트, 모던 파이썬 실전 #6 가 그 출발점입니다. - 컨트랙트 테스트 — 백엔드와 프런트엔드의 계약을 OpenAPI / Pact로 검증.
- mutation testing — 테스트가 “정말로 잡고 있는지” 를 검증하는 메타 도구 (Stryker).
정리 #
- E2E는 핵심 사용자 흐름 5~10개 — 그 이상은 유지보수 부담. 단위/통합이 잡는 것은 거기에 맡기기.
- Playwright의 locator는 lazy + auto-waiting.
waitFor거의 필요 없음 —await expect(locator).toBe...()가 자동 폴링. - queries는 RTL과 같은 철학 —
getByRole,getByLabel우선. 사용자가 보는 방식. storageState로 로그인 상태 공유 — 매 테스트에서 로그인 흐름 반복 안 함.page.route로 Playwright 안에서도 네트워크 모킹 가능. 단, E2E의 본 가치는 진짜 백엔드 검증.- CI 워크플로우는 unit + e2e 병렬. trace / report를 artifact로 업로드해 PR에서 다운받기.
- 커버리지는 참고, 자동 차단 룰은 보통 발등 찍기.
- 트랙 6편의 결 — 정적 → 통합 → 단위 → E2E. 시간의 분배를 의식적으로.
테스팅 트랙은 여기서 마무리합니다. 리액트 트랙 / 타입스크립트 트랙 의 자연스러운 확장이었고, 이 트랙을 마치면 거의 모든 React 코드에 테스트를 짤 손이 손에 잡혀요. 그 다음은 — 내 프로젝트 한 곳에 직접 도입해 보는 단계입니다. 작은 함수 한두 개부터.