테스팅 강좌 #6 Playwright로 E2E와 CI 통합 — 트랙 마무리

12 분 소요

테스트 트랙의 마지막 글입니다. #1~5 가 한 컴포넌트 / 한 모듈 안을 다뤘다면, 이번 글은 진짜 브라우저에서 실제 사용자 시나리오를 끝까지 따라가는 E2E.

테스팅 강좌에서 이번 글의 위치:

이번 글은 도구는 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 도구는 둘이 양강. 결만 짚으면:

PlaywrightCypress
브라우저Chromium, Firefox, WebKit (Safari)Chromium, Firefox, WebKit (실험적)
실행 모델Out-of-process (브라우저 자동화)In-process (브라우저 안에서)
다중 탭 / iframe자연스럽게 지원까다로움
병렬 실행기본 지원유료 (Cypress Cloud)
모바일 에뮬레이션강함보통
학습 곡선낮음가장 낮음
문서좋음매우 좋음

새 프로젝트에서 안 고를 이유가 거의 없는 게 Playwright가 됐습니다. WebKit 지원, 병렬 실행 무료, MS가 활발히 개발 중. Cypress가 강한 영역은 가르치기 쉬운 DX와 시각적 디버거 정도.

이 글은 Playwright 기준.

셋업 #

가장 빠른 시작 — 공식 init 명령.

Playwright 설치
pnpm create playwright

물어보는 것:

  • TypeScript? → 예
  • 테스트 디렉터리? → e2e (또는 tests)
  • GitHub Actions 워크플로우 추가? → 예 (CI 절에서 더 다룸)
  • 브라우저 설치? → 예 (Chromium / Firefox / WebKit 다운로드, 약 500MB)

생성된 playwright.config.ts 일부:

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 #

가장 단순한 테스트.

e2e/home.spec.ts
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를 찾지 않습니다.

locator 의 역할
const button = page.getByRole('button', { name: '저장' });
// 여기서는 아직 DOM 을 안 봄

await button.click();
// 이 시점에 element 를 찾고, 못 찾으면 자동 대기

이 구조 덕분에 **자동 대기(auto-waiting)**가 자연스러워요. RTL처럼 waitFor / findBy를 쓸 필요가 거의 없습니다 — await 자체가 element를 기다리니까.

auto-waiting 예
await page.getByRole('button', { name: '저장' }).click();
// 버튼이 나타나기를 기다리고, enabled 가 되기를 기다리고, 클릭

await expect(page.getByRole('alert')).toHaveText('저장됨');
// alert 가 나타나기를 기다리고, 텍스트가 일치할 때까지

expect도 같은 결. **await expect(locator).toBeVisible()**이 5초간 (기본) 폴링하면서 기다리고, 끝까지 안 되면 그제야 실패.

로그인 흐름 — 진짜 E2E #

좀 더 진지한 시나리오. 로그인 → 대시보드 → 로그아웃.

e2e/auth.spec.ts
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가 이 문제를 풉니다.

e2e/auth.setup.ts
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 });
});
playwright.config.ts — projects 분리
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 패턴이 이를 정리합니다.

e2e/pages/LoginPage.ts
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가 자동으로 만들어 주는 워크플로우.

.github/workflows/playwright.yml
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-artifact with if: 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().

visual snapshot
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.tsviewportbaseURL이 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 코드에 테스트를 짤 손이 손에 잡혀요. 그 다음은 — 내 프로젝트 한 곳에 직접 도입해 보는 단계입니다. 작은 함수 한두 개부터.

X