「프론트엔드 테스트 제대로 하기」 시리즈의 13편입니다. 전체 목차 보기 · 용어집

E2E 테스트가 열 개를 넘어가면 두 가지가 아파옵니다. 테스트마다 복붙되는 준비 코드, 그리고 순서대로 도느라 느려지는 스위트. Playwright의 픽스처(반복 준비를 테스트에 주입하는 장치)와 병렬 실행이 이 둘을 한 번에 해결합니다 — 이 글에서 커스텀 픽스처, 인증 재사용(storageState), 병렬 안전 수칙까지 정리해요.

시리즈로 따라오셨다면 E2E가 슬슬 쌓이기 시작한 딱 그 시점이고, 검색으로 처음 오셨어도 괜찮습니다. 예제는 자급자족하고, 기초가 필요하면 입문 편이 있어요.

실습 코드: 이 편의 상태가 step-13 태그에 담겨 있습니다(픽스처·Page Object로 준비 코드를 공유한 상태 — 디버깅·axe 같은 뒤 편 코드는 아직 없어요). StackBlitz에서 바로 열기도 됩니다.

군살을 뺄 곳부터 짚고 갑니다.

  • 커스텀 픽스처로 준비 코드 공유
  • 인증 상태 저장·재사용(storageState) 패턴
  • 병렬 실행과 테스트 격리
저장된 인증 상태를 여러 테스트가 재사용하고 병렬로 실행되는 구조를 보여주는 다이어그램
저장된 인증 상태를 여러 테스트가 재사용하고 병렬로 실행되는 구조를 보여주는 다이어그램

커스텀 픽스처

사실 여러분은 픽스처를 이미 쓰고 계셨어요. 입문 편부터 테스트마다 받아온 { page } — 그게 Playwright의 내장 픽스처입니다. 테스트 시작 전에 깨끗한 탭을 준비해서 건네주고, 끝나면 알아서 치우죠. 연극으로 치면 무대 스태프예요. 막이 오르기 전에 소품을 제자리에 놓고, 막이 내리면 정리하는.

오늘 배우는 건 그 스태프에게 우리 일을 더 가르치는 것입니다. “접속하고, 로딩을 기다리고"를 매번 쓰는 대신, 준비된 대시보드를 픽스처로 받습니다. (repo e2e/fixtures.ts)

ts
import { test as base, type Page } from '@playwright/test'

class DashboardPage {
  readonly page: Page

  constructor(page: Page) {
    this.page = page
  }

  async goto() {
    await this.page.goto('/')
    await this.page.getByText('Alice Kim').waitFor()  // 목록 로딩까지 끝나야 '준비 완료'
  }

  search(q: string) {
    return this.page.getByRole('searchbox', { name: '검색' }).fill(q)
  }

  row(name: string) {
    // 이름이 포함된 목록 행을 찾는 로케이터 (쓰는 순간 조회)
    return this.page.getByRole('listitem').filter({ hasText: name })
  }
}

export const test = base.extend<{ dashboard: DashboardPage }>({
  dashboard: async ({ page }, use) => {
    const dashboard = new DashboardPage(page)
    await dashboard.goto()      // use 이전 = 준비 (접속 + 로딩 대기)
    await use(dashboard)        // 이 순간 테스트 본문이 실행된다
  },
})
export { expect } from '@playwright/test'

(생성자에서 constructor(readonly page: Page) 축약을 쓰지 않은 건 의도예요 — 최신 Vite 템플릿의 erasableSyntaxOnly 옵션과 충돌하거든요. 데모 repo에서 typecheck가 잡아준 실화입니다.)

구조가 낯설 테니 흐름을 풀어볼게요. DashboardPage는 “대시보드 페이지를 다루는 법"을 모아둔 클래스 — 이런 패턴을 Page Object라고 부릅니다. 그리고 base.extend({ dashboard: ... })가 픽스처 등록이에요. 픽스처 함수의 구조는 샌드위치입니다.

  • use(...) 이전 — 준비 단계. dashboard를 만들고 goto()까지 마칩니다
  • use(dashboard) — “자, 무대 준비 끝” — 이 순간 테스트 본문이 실행되고, 인자 { dashboard }로 배달됩니다
  • use(...) 이후 — 정리 단계. (여기선 정리할 게 없어 비어 있지만, DB 연결을 닫는 식의 뒷정리가 이 자리에 옵니다)
픽스처의 샌드위치 구조 다이어그램 - base.extend로 등록된 픽스처 함수에서 use 호출 이전은 준비 단계로 dashboard 생성과 goto 실행, use(dashboard) 호출 시점에 테스트 본문이 실행되며 dashboard가 인자로 배달되고, use 이후는 정리 단계입니다. 무대 스태프가 막 전에 소품을 놓고 막 후에 정리하는 비유가 곁들여져 있습니다
픽스처의 샌드위치 구조 다이어그램 - base.extend로 등록된 픽스처 함수에서 use 호출 이전은 준비 단계로 dashboard 생성과 goto 실행, use(dashboard) 호출 시점에 테스트 본문이 실행되며 dashboard가 인자로 배달되고, use 이후는 정리 단계입니다. 무대 스태프가 막 전에 소품을 놓고 막 후에 정리하는 비유가 곁들여져 있습니다

테스트 본문은 시나리오에만 집중하게 됩니다. (repo e2e/search-with-fixture.spec.ts)

ts
import { test, expect } from './fixtures'

test('픽스처로 준비를 공유해 검색한다', async ({ dashboard }) => {
  await dashboard.search('bob')

  await expect(dashboard.row('Bob Lee')).toBeVisible()
  await expect(dashboard.row('Alice Kim')).toHaveCount(0)
})

접속도, 로딩 대기도 본문에서 사라졌죠. 임포트가 '@playwright/test'가 아니라 './fixtures'인 것만 눈여겨봐 주세요 — 우리가 가르친 스태프가 딸린 test를 쓰는 겁니다.


인증은 한 번만 (패턴)

데모 앱엔 로그인이 없지만, 실무의 필수 패턴이라 짚어둡니다. 전역 셋업에서 한 번 로그인하고 상태를 저장한 뒤, 모든 테스트가 재사용해요.

ts
// global-setup: 로그인 후 상태 저장
await page.context().storageState({ path: 'playwright/.auth/user.json' })

// playwright.config.ts
use: { storageState: 'playwright/.auth/user.json' }

storageState가 저장하는 건 쿠키와 localStorage — 즉 “로그인됐다"는 증거 일체입니다. 이 파일을 물려받은 테스트는 처음부터 로그인된 상태로 시작해요. 매 테스트 로그인 대비 수십 초씩 아끼고, 로그인 UI가 바뀌어도 테스트 전체가 흔들리지 않습니다(고칠 곳은 전역 셋업 한 군데).


병렬과 격리

repo 설정의 fullyParallel: true로 파일·테스트가 병렬 실행됩니다. 검색 테스트 둘을 실제로 돌려보면:

bash
npx playwright test -g "검색"

Running 2 tests using 2 workers
2 [chromium] › e2e/dashboard.spec.ts:4:1 › 대시보드에서 사용자를 검색한다 (461ms)
1 [chromium] › e2e/search-with-fixture.spec.ts:3:1 › 픽스처로 준비를 공유해 검색한다 (465ms)
  2 passed (1.8s)

using 2 workers — 브라우저 두 대가 동시에 돌았습니다. 완료 순서(2번이 먼저)가 파일 순서와 다른 것도 그 증거예요. 테스트가 둘뿐인 지금은 체감이 없지만, 수십 개쯤 쌓이면 이 워커들이 진가를 발휘합니다.

대신 조건이 하나 — 테스트끼리 상태를 공유하면 안 됩니다. 병렬 사고는 이런 식으로 나요: 테스트 A가 “항목을 삭제하면 목록이 빈다"를 검증하는 동안, 같은 계정을 쓰는 테스트 B가 그 목록을 읽고 있었다면? B는 영문도 모른 채 실패합니다. 순서대로 돌 땐 멀쩡하다가 병렬에서만 터지니, 최악의 flaky가 되죠. 각 테스트는 자기만의 데이터로 시작하고 끝나야 병렬에서 안전합니다. (전역 변수, 같은 계정의 데이터 수정이 단골 범인이에요.)


한 장 요약

  • 반복되는 준비(접속·로딩 대기·로그인)는 픽스처로 — base.extend로 등록하면 테스트 인자로 배달됩니다
  • 페이지 다루는 법은 Page Object(클래스)로 모으면 UI가 바뀌어도 한 곳만 고치면 됨
  • 인증은 한 번만: 전역 셋업에서 로그인 → storageState로 저장 → 전 테스트가 재사용
  • fullyParallel: true로 병렬 실행 — 단, 테스트끼리 상태 공유 금지(전역 변수·같은 계정 데이터 수정이 단골 범인)
  • 픽스처 본문이 비대해지면 신호 — 준비는 픽스처에, 검증은 테스트에

정리하며

빠르고 깔끔한 E2E가 됐어요. 그래도 테스트는 깨집니다. 다음 편은 원인을 추적하는 디버깅입니다.

테스트 스위트가 5분에서 40초로 줄어드는 순간, 병렬의 신봉자가 됩니다. 단, 공유 상태를 하나라도 남기면 그 40초는 지옥이 되지만요.

레벨업: 픽스처와 병렬화로 E2E 스위트를 빠르고 깔끔하게 유지할 수 있습니다.

다음 편: 트레이스·디버깅·리트라이로 원인 추적

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.