FE
[SvelteKit] Playwright 디버깅부터 운영 코드 안정화까지 — 실패로 배우는 실전 가이드
| 서론
안녕하세요 팡일입니다!
지난 Vitest를 활용한 컴포넌트·유닛 테스트에 이어서, 이번에는 Playwright 기반의 E2E(End-to-End) 테스트 구축기를 공유해보려고 합니다.
| Playwright 시작 준비 — 설치부터 기본 실행까지
Playwright E2E 테스트를 시작하려면 몇 가지 기본 환경을 먼저 만들어야 합니다. SvelteKit 프로젝트에서는 npx playwright init이 자동으로 구성 파일을 생성해주지 않는 경우가 종종 있어, 수동으로 설정을 만들어야 합니다.
아래 단계대로 따라가면 Playwright 테스트를 실행할 수 있는 최소 환경이 완성됩니다.
1) Playwright 설치
npm install -D @playwright/testPlaywright의 테스트 러너를 개발 의존성(devDependency)으로 설치합니다. 이 패키지 하나만으로 테스트 실행, assertion, 테스트 스위트 구성이 모두 가능합니다.
2) 브라우저 드라이버 설치
npx playwright installPlaywright는 실제 브라우저(Chromium, Firefox, WebKit)를 구동해야 하므로 각 브라우저를 Playwright 전용 드라이버와 함께 설치합니다. 이 작업이 누락되면 테스트가 동작하지 않습니다.
3) 기본 테스트 환경이 자동 생성되지 않는 경우
일부 프로젝트(SvelteKit + Vite 구조 포함)에서는 npx playwright init을 실행해도 테스트 템플릿이 자동 생성되지 않을 수 있습니다.
이 경우, 테스트 디렉토리와 설정 파일을 직접 만들어야 합니다.
(1) 테스트 폴더 생성
mkdir -p tests/playwright: Playwright가 테스트 파일을 스캔할 경로를 직접 만들어 줍니다. 프로젝트 루트와 테스트 코드가 섞이지 않아서 관리도 편해집니다.
(2) 기본 테스트 파일 생성 — example.spec.ts
// tests/playwright/example.spec.ts
import { test, expect } from '@playwright/test';
test('homepage loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/re-log/);
});: Playwright는 .spec.ts 확장자의 테스트 파일을 자동 인식합니다. 이 코드는 “홈페이지가 열리고, 제목이 re-log을 포함하는지”를 확인하는 가장 최소 단위의 예제입니다.
(3) playwright.config.ts 추가
: 프로젝트 루트에 파일 생성
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/playwright',
use: {
baseURL: 'http://localhost:5173',
headless: true,
},
});: Playwright의 전역 설정 파일입니다. 이 설정이 없으면 테스트가 어느 폴더를 읽어야 하는지도 모르게 됩니다.
testDir: 테스트 파일 위치 지정
baseURL: page.goto(’/’) 와 같이 짧은 URL을 쓸 수 있도록 설정
headless: 브라우저를 백그라운드 모드로 실행
(4) package.json 스크립트 추가
{
"scripts": {
"test:e2e": "playwright test"
}
}: npm run test:e2e 로 Playwright 테스트를 바로 실행할 수 있도록 스크립트를 추가합니다. CI 환경에서도 동일한 명령어를 그대로 사용할 수 있어 편리합니다.
(5) 실행
npm run test:e2e이제 Playwright가 테스트 파일을 읽고 실제 브라우저를 띄운 뒤 홈페이지 로딩 테스트를 수행합니다.
그리고 바로 이 시점에서… 저 역시 “첫 번째 난관”을 맞닥뜨리게 되었죠.
| 첫 번째 난관 — “제목이 왜 비어 있지…?”
// src/tests/playwright/example.spec.ts
import { test, expect } from '@playwright/test';
test('homepage loads', async ({ page }) => {
await page.goto('http://localhost:5173');
await expect(page).toHaveTitle(/re-log/);
});Playwright 기본 템플릿 테스트(example.spec.ts)를 실행했는데, 첫 줄부터 실패했습니다.
Error: expect(page).toHaveTitle(expected) failed
Expected pattern: /SvelteKit/
Received string: ""브라우저에서는 정상적으로 페이지가 보이는데, 테스트에서는 title이 **빈 문자열(””)**로 나오는 이상한 상황이었죠..
원인을 분석해보니 금방 찾을 수 있었습니다.
| 원인: 페이지 자체에 title이 없었다
SvelteKit은 src/app.html의 %sveltekit.head% 영역에 각 페이지의 <svelte:head> 내용을 주입합니다.
그런데 정작 메인 페이지(src/routes/(public)/+page.svelte)에는 타이틀 설정이 없었죠.
1) <svelte:head> 추가
<svelte:head>
<title>re-log</title>
</svelte:head>그리고 테스트 코드도 일치하도록 수정합니다.
await expect(page).toHaveTitle(/re-log/);첫 번째 문제는 깔끔하게 해결!
하지만 진짜 고생은 여기서부터 시작이었습니다…
| 두 번째 난관 — “버튼을 눌렀는데 왜 페이지가 안 넘어가?”
홈페이지에서 “시작하기” 버튼 → /login 페이지 이동을 테스트하는 코드였습니다. 그런데 결과가 아주 기괴했습니다.
Firefox: 성공
Chromium/WebKit: 실패
// 에러 메시지:
Expected: /login
Received: /
Timeout waiting for navigation...“분명 클릭 이벤트는 발생했는데, 네비게이션이 이루어지지 않는다?”
1) 원인 분석: SvelteKit hydration + Race Condition
Playwright는 페이지가 로드되면 즉시 테스트를 실행합니다. 하지만 그때 SvelteKit은 아직 클라이언트 라우터를 완전히 준비하지 않았을 수도 있습니다.
마치 이를 비유하면 아래와 같았습니다.
손님(Playwright) 은 식당에 도착했고
주방(SvelteKit Router) 은 아직 영업 준비 중이었던 것.
click 이벤트가 실행되었지만, 라우터가 준비되기 전이라 이벤트가 정상적으로 처리되지 않은 거죠.
2) 해결: 네트워크 안정 상태 + 클릭/URL을 동시에 기다리기
핵심은 두 가지입니다.
(1) SvelteKit 라우터가 완전히 준비될 때까지 기다린다
await page.waitForLoadState('networkidle');(2) 클릭과 URL 변화를 동시에 기다린다 (= Race Condition 관리)
await Promise.all([
page.waitForURL('**/login'),
targetButton.click()
]);이 두 단계가 결합되어야 모든 브라우저에서 테스트가 안정됩니다.
3) 최종 테스트 코드
import { test, expect } from '@playwright/test';
test('navigate to login page from homepage', async ({ page }) => {
await page.goto('/');
// SvelteKit 라우터 준비 보장
await page.waitForLoadState('networkidle');
const targetButton = page.getByRole('button', { name: '시작하기' }).last();
// 클릭 & 이동을 동시에 기다리기
await Promise.all([
page.waitForURL('**/login'),
targetButton.click()
]);
await expect(page).toHaveURL('/login');
});이 한 줄 고치고 나서… Chromium / Firefox / WebKit 모두 100% 통과!
드디어 레이스 컨디션과의 전쟁 종료!
| Playwright의 진짜 힘 — codegen
Playwright를 쓰면서 가장 많이 놀랐던 기능이 바로, codegen입니다.
npx playwright codegen http://localhost:5173브라우저를 열고 실제로 회원가입 흐름을 클릭해보면, 그 모든 동작이 자동으로 코드로 기록됩니다.
1) 생성된 코드 일부
await page.getByRole('textbox', { name: '이름' }).fill('테스트맨');
await page.getByRole('textbox', { name: '이메일' }).fill('test@example.com');
await page.getByRole('button', { name: '회원가입', exact: true }).click();그대로 쓰기엔 너무 장황하지만, 이 동작 중 필요한 부분만 추리면 금방 좋은 E2E 테스트가 됩니다.
2) 개선된 회원가입 테스트 예시
test('새로운 사용자가 성공적으로 회원가입할 수 있다', async ({ page }) => {
const uniqueEmail = `testUser_${Date.now()}@example.com`;
await page.goto('/register');
await page.getByRole('textbox', { name: '이름' }).fill('테스트맨');
await page.getByRole('textbox', { name: '이메일' }).fill(uniqueEmail);
await page.getByRole('textbox', { name: '비밀번호', exact: true }).fill('password123');
await page.getByRole('textbox', { name: '비밀번호 확인' }).fill('password123');
await page.getByRole('button', { name: '회원가입', exact: true }).click();
await expect(page).toHaveURL('/login');
});| 마무리
Playwright E2E 테스트는 “실제 사용자를 그대로 시뮬레이션하는” 가장 강력한 테스트 방식입니다. SvelteKit에서는 hydration·라우팅 타이밍 때문에 특유의 어려움이 있지만, 핵심 개념만 이해하면 놀라울 만큼 안정적인 테스트 환경을 구축할 수 있습니다.
아래의 요소들만 잘 활용해도 훌륭한 E2E 테스트가 완성됩니다.
hydration 타이밍 관리
SPA 라우팅 준비 상태 대기
클릭 & URL 변화를 동시에 기다리기
codegen으로 빠르게 테스트 생성하기