E2E 테스트가 열 개를 넘어가면 두 가지가 아파옵니다. 테스트마다 복붙되는 준비 코드, 그리고 순서대로 도느라 느려지는 스위트. Playwright의 픽스처(반복 준비를 테스트에 주입하는 장치)와 병렬 실행이 이 둘을 한 번에 해결합니다 — 이 글에서 커스텀 픽스처, 인증 재사용(storageState), 병렬 안전 수칙까지 정리해요.
시리즈로 따라오셨다면 E2E가 슬슬 쌓이기 시작한 딱 그 시점이고, 검색으로 처음 오셨어도 괜찮습니다. 예제는 자급자족하고, 기초가 필요하면 입문 편이 있어요.
실습 코드: 이 편의 상태가
step-13태그에 담겨 있습니다(픽스처·Page Object로 준비 코드를 공유한 상태 — 디버깅·axe 같은 뒤 편 코드는 아직 없어요). StackBlitz에서 바로 열기도 됩니다.
군살을 뺄 곳부터 짚고 갑니다.
- 커스텀 픽스처로 준비 코드 공유
- 인증 상태 저장·재사용(storageState) 패턴
- 병렬 실행과 테스트 격리

커스텀 픽스처#
사실 여러분은 픽스처를 이미 쓰고 계셨어요. 입문 편부터 테스트마다 받아온 { page } — 그게 Playwright의 내장 픽스처입니다. 테스트 시작 전에 깨끗한 탭을 준비해서 건네주고, 끝나면 알아서 치우죠. 연극으로 치면 무대 스태프예요. 막이 오르기 전에 소품을 제자리에 놓고, 막이 내리면 정리하는.
오늘 배우는 건 그 스태프에게 우리 일을 더 가르치는 것입니다. “접속하고, 로딩을 기다리고"를 매번 쓰는 대신, 준비된 대시보드를 픽스처로 받습니다. (repo e2e/fixtures.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 연결을 닫는 식의 뒷정리가 이 자리에 옵니다)

테스트 본문은 시나리오에만 집중하게 됩니다. (repo e2e/search-with-fixture.spec.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를 쓰는 겁니다.
인증은 한 번만 (패턴)#
데모 앱엔 로그인이 없지만, 실무의 필수 패턴이라 짚어둡니다. 전역 셋업에서 한 번 로그인하고 상태를 저장한 뒤, 모든 테스트가 재사용해요.
// 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로 파일·테스트가 병렬 실행됩니다. 검색 테스트 둘을 실제로 돌려보면:
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 스위트를 빠르고 깔끔하게 유지할 수 있습니다.
다음 편: 트레이스·디버깅·리트라이로 원인 추적
낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.
