
Playwright로 안정적인 E2E 테스트를 작성하는 데 필요한 테스트 격리, Locator, Auto-waiting, Fixture, 인증 재사용, 네트워크 모킹과 CI 전략을 정리합니다.
Playwright를 사용하면 브라우저에서 일어나는 사용자 행동을 자동화하고, 서비스의 주요 흐름이 정상적으로 이어지는지 검증할 수 있습니다. 하지만 클릭과 입력 API를 아는 것만으로는 안정적인 테스트를 만들기 어렵습니다. 테스트마다 상태를 어떻게 격리하고, 어떤 조건을 기다리며, 실패 원인을 어떻게 추적할지까지 함께 설계해야 합니다.
이 글에서는 Playwright로 E2E(End-to-End) 테스트를 작성할 때 알아야 할 핵심 개념을 하나의 흐름으로 정리합니다. 로그인이나 게시글 작성 같은 사용자 시나리오를 안정적으로 검증하고 싶은 개발자를 대상으로 합니다.
Playwright는 브라우저를 코드로 조작해 사용자의 행동을 자동화하는 도구입니다. Chromium, Firefox, WebKit에서 페이지 이동, 입력, 클릭 등을 수행하고 그 결과를 검증할 수 있습니다. Playwright Test는 여기에 테스트 실행, 검증, Fixture, 리포트 기능을 더한 테스트 프레임워크입니다.
E2E 테스트는 사용자의 관점에서 서비스의 전체 흐름이 정상적으로 동작하는지 확인합니다. 개별 함수의 반환값을 확인하는 단위 테스트와 달리, 브라우저에서 여러 기능이 연결된 결과를 검증합니다.
로그인 화면 접속
→ 이메일과 비밀번호 입력
→ 로그인 버튼 클릭
→ 인증 처리
→ 로그인 후 화면 확인아래 테스트는 로그인 화면에 값을 입력한 뒤, 로그인에 성공해 /home으로 이동하는지 확인합니다. 테스트용 계정과 로그인 화면이 준비되어 있고, Playwright 설정 파일에 baseURL을 지정했다고 가정합니다.
import { test, expect } from "@playwright/test";
test("로그인할 수 있다", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("이메일").fill("test@example.com");
await page.getByLabel("비밀번호").fill("test-password");
await page.getByRole("button", { name: "로그인", exact: true }).click();
await expect(page).toHaveURL("/home");
});test는 시나리오를 정의하고, page는 브라우저의 탭을 나타냅니다. 요소를 찾고 사용자의 행동을 재현한 뒤, expect로 결과를 검증하는 것이 기본 구조입니다.
E2E 테스트는 실제 사용자 흐름을 확인하는 데 유용하지만 실행 비용이 크고 외부 환경의 영향을 받습니다. 모든 경우를 E2E 테스트로 확인하기보다 로그인, 게시글 작성처럼 중요한 흐름에 집중하세요. 세부 로직은 단위 테스트와 통합 테스트로 검증하는 편이 효율적입니다.
Playwright의 브라우저 환경은 Browser, BrowserContext, Page의 계층으로 구성됩니다.
Browser
├── BrowserContext A
│ ├── Page A-1
│ └── Page A-2
└── BrowserContext B
└── Page B-1Browser: Chromium, Firefox, WebKit과 같은 브라우저 인스턴스입니다.BrowserContext: 쿠키와 저장소를 다른 Context와 분리한 독립적인 브라우저 환경입니다.Page: Context 안에서 열린 하나의 탭이나 팝업입니다.같은 Context에 속한 Page는 쿠키를 공유합니다. 출처가 같다면 localStorage도 공유하지만, sessionStorage는 탭마다 분리됩니다. 여러 탭에서 같은 사용자로 동작하는 상황은 하나의 Context에서 여러 Page를 열어 테스트할 수 있습니다.
Playwright Test의 기본 page, context Fixture를 사용하면 테스트마다 새로운 BrowserContext를 받습니다. 앞선 테스트에서 로그인하거나 저장소에 값을 기록해도 다음 테스트에 이어지지 않아요.
테스트 A → Context A → A의 쿠키와 저장소
테스트 B → Context B → B의 쿠키와 저장소테스트 격리의 목적은 실행 순서나 다른 테스트의 성공 여부에 의존하지 않는 테스트를 만드는 것입니다. 게시글 수정 테스트가 게시글 작성 테스트에서 만든 데이터를 사용한다고 가정해 보겠습니다. 작성 테스트가 실패하거나 수정 테스트만 따로 실행하면 수정 테스트도 실패합니다. 따라서 각 테스트는 자신에게 필요한 상태를 직접 준비해야 합니다.
BrowserContext가 분리되어도 서버의 데이터베이스까지 자동으로 분리되지는 않습니다. 여러 테스트가 같은 계정이나 게시글을 동시에 변경하면 충돌할 수 있습니다. 브라우저 상태뿐 아니라 테스트별 데이터 생성과 정리, 계정 분리도 함께 설계해야 합니다.
화면이 다시 렌더링되거나 네트워크 응답이 늦어져도 안정적으로 동작하려면 요소를 찾는 기준과 기다리는 조건이 명확해야 합니다. Playwright에서는 Locator, Auto-waiting, Web-first Assertion이 이 역할을 나눠 맡아요.
Locator는 조작하거나 검증할 요소를 찾는 방법을 표현하는 객체입니다. 한 번 찾은 DOM 요소를 계속 보관하지 않고, 실제 동작 시점에 조건과 일치하는 요소를 다시 찾습니다. 따라서 화면이 다시 렌더링되는 상황에도 대응할 수 있습니다.
| Locator | 요소를 찾는 기준 |
|---|---|
getByRole() | 버튼, 링크 등의 역할과 접근 가능한 이름 |
getByLabel() | 입력 요소에 연결된 label |
getByText() | 화면에 표시되는 텍스트 |
getByPlaceholder() | 입력 요소의 placeholder |
getByTestId() | 기본적으로 data-testid 속성 |
CSS 클래스나 복잡한 DOM 경로보다 사용자가 인식하는 역할과 이름을 기준으로 요소를 찾는 편이 좋습니다. 버튼의 클래스가 바뀌어도 역할과 이름이 유지되면 테스트를 수정하지 않아도 됩니다. 화면에 보이는 정보만으로 요소를 안정적으로 구분하기 어렵다면 getByTestId()를 사용할 수 있습니다.
const loginButton = page.getByRole("button", {
name: "로그인",
exact: true,
});
await page.getByLabel("이메일").fill("test@example.com");
await loginButton.click();클릭처럼 하나의 요소를 대상으로 하는 동작에서 Locator가 여러 요소와 일치하면 오류가 발생합니다. 무조건 first()로 첫 번째 요소를 선택면 원하는 대상이 아닌 다른 대상이 선택될 수 있습니다. 검색 영역을 제한하거나 이름을 구체적으로 지정해 의도한 요소를 찾는 것이 좋습니다.
요소를 찾은 뒤에는 Action으로 사용자 행동을 수행합니다.
click(): 요소를 클릭합니다.fill(): 입력 요소에 값을 채웁니다.check(), uncheck(): 체크박스의 선택 상태를 바꿉니다.selectOption(): <select> 요소의 항목을 선택합니다.press(): 키보드 키를 입력합니다.hover(): 요소 위로 마우스를 이동합니다.Page → Locator로 요소 지정 → Action 수행 → 결과 검증Playwright는 Action을 수행하기 전에 대상 요소가 동작 가능한 상태인지 확인합니다. 예를 들어 click()은 대상이 하나인지, 화면에 보이는지, 위치가 안정적인지, 클릭 이벤트를 받을 수 있는지, 활성화되어 있는지 등을 확인합니다. Auto-waiting은 이 조건을 충족할 때까지 제한 시간 안에서 기다립니다.
따라서 임의의 시간을 기다리는 waitForTimeout()보다 필요한 요소에 바로 Action을 수행하는 편이 안정적입니다.
// 실행 환경에 따라 3초가 너무 길거나 짧을 수 있다.
await page.waitForTimeout(3000);
await page.getByRole("button", { name: "저장", exact: true }).click();
// 클릭할 수 있는 상태가 될 때까지 Playwright가 기다린다.
await page.getByRole("button", { name: "저장", exact: true }).click();고정 시간 대기는 빠른 환경에서 불필요한 시간을 소비하고, 느린 환경에서는 시간이 부족해 실패합니다. 예를 들어 화면이 0.5초 만에 준비되어도 waitForTimeout(3000)은 3초를 모두 기다립니다. 반대로 준비에 4초가 걸리면 아직 버튼을 클릭할 수 없는데도 다음 코드를 실행합니다.
여기서 "다음 단계로 넘어갈 수 있는 조건"은 시간이 아니라 화면의 상태를 뜻합니다. 저장 버튼을 누르려면 버튼이 클릭 가능한 상태여야 하고, 저장 결과를 확인하려면 완료 메시지가 보여야 합니다. Playwright에서는 Action과 Web-first Assertion으로 이런 조건을 표현합니다.
// 버튼이 클릭 가능한 상태가 되면 즉시 클릭한다.
await page.getByRole("button", { name: "저장", exact: true }).click();
// 저장 결과가 화면에 나타나면 즉시 다음 단계로 넘어간다.
await expect(page.getByText("저장되었습니다.", { exact: true })).toBeVisible();조건 기반 대기에도 최대 대기 시간인 timeout은 있습니다. 다만 timeout을 항상 전부 기다리는 것이 아니라 조건을 만족하는 즉시 다음 단계로 넘어갑니다. 따라서 빠른 환경에서는 테스트가 빨리 끝나고, 느린 환경에서는 timeout 안에서 필요한 만큼 기다릴 수 있습니다.
Assertion은 행동 이후의 결과가 기대한 상태인지 확인합니다. Playwright의 Web-first Assertion은 조건을 즉시 만족하지 않더라도 제한 시간 동안 반복해서 확인합니다.
앞의 예제에서 click()은 저장 버튼이 동작 가능한 상태가 되기를 기다리고, toBeVisible()은 저장 완료 메시지가 나타나기를 기다립니다. 화면의 변화를 기다릴 때는 이처럼 Locator를 expect에 직접 전달합니다.
Auto-waiting은 행동을 수행할 수 있는 조건을 기다리고, Web-first Assertion은 행동 이후 기대한 결과를 기다립니다. 버튼을 클릭할 수 있다는 사실이 서버의 저장 처리까지 끝났다는 뜻은 아니므로 두 역할을 구분해야 합니다.
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toHaveText("완료");
await expect(locator).toHaveValue("입력값");
await expect(page).toHaveURL("/home");
await expect(page).toHaveTitle("Home");모든 expect가 자동으로 재시도하는 것은 아닙니다. 아래 코드는 isVisible()을 한 번 실행해 얻은 불리언 값을 검증하므로 화면 상태가 바뀔 때까지 기다리지 않아요.
expect(await locator.isVisible()).toBe(true);비동기적으로 변하는 화면 상태는 Locator를 직접 검증하세요.
await expect(locator).toBeVisible();테스트가 많아지면 관련 시나리오를 묶고 반복되는 준비 과정을 정리해야 합니다. test.describe()로 테스트를 그룹화하고, Hook으로 실행 전후의 작업을 정의할 수 있습니다.
아래 예제는 각 로그인 테스트를 시작하기 전에 로그인 화면으로 이동합니다.
test.describe("로그인", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
});
test("잘못된 비밀번호를 입력하면 오류를 보여준다", async ({ page }) => {
await page.getByLabel("이메일").fill("test@example.com");
await page.getByLabel("비밀번호").fill("wrong-password");
await page.getByRole("button", { name: "로그인", exact: true }).click();
await expect(page.getByRole("alert")).toHaveText(
"로그인 정보를 확인해주세요.",
);
});
});beforeEach, afterEach: 각 테스트의 실행 전후에 호출됩니다.beforeAll, afterAll: 해당 파일이나 그룹을 실행하는 워커 프로세스에서 전체 테스트 전후에 호출됩니다.beforeAll을 전체 테스트 실행에서 항상 한 번만 호출되는 함수로 이해하면 안 됩니다. 워커가 여러 개이거나 실패 후 워커를 다시 시작하면 여러 번 호출될 수 있습니다. 테스트마다 생성되는 page, context Fixture도 beforeAll에서는 사용할 수 없습니다.
Fixture는 테스트에 필요한 자원이나 환경을 준비하고, 테스트가 끝나면 정리하는 구조입니다. 테스트 함수의 async ({ page }) => {}에서 page는 직접 만든 변수가 아니라 Playwright가 주입한 기본 Fixture입니다.
| Fixture | 역할 |
|---|---|
browser | 워커에서 공유하는 브라우저 인스턴스 |
context | 테스트별로 격리된 브라우저 환경 |
page | 해당 테스트의 브라우저 탭 |
request | HTTP 요청을 직접 보내는 APIRequestContext |
Hook은 특정 시점에 실행할 작업을 정의하고, Fixture는 테스트에 필요한 자원과 생명주기를 관리합니다. 먼저 기본 Fixture를 활용하고, 반복되는 준비와 정리 로직이 많아지면 Custom Fixture로 분리하세요.
E2E 테스트의 속도와 독립성은 검증 대상이 아닌 준비 과정을 얼마나 효율적으로 구성하는지에 따라 달라집니다. 로그인 상태는 storageState로 재사용하고, 테스트 데이터는 API로 직접 준비할 수 있습니다.
게시글 작성이나 회원 정보 수정처럼 로그인이 전제인 기능을 검증할 때마다 UI로 로그인하면 실행 시간이 늘어납니다. 로그인 화면이 바뀌면 로그인 자체가 검증 대상이 아닌 테스트까지 영향을 받아요. 이때 인증 상태를 파일에 저장해 여러 테스트에서 재사용할 수 있습니다.
인증 준비용 setup project
→ 테스트 계정으로 로그인
→ 로그인 완료 확인
→ storageState 파일 저장
→ 의존하는 테스트를 저장된 상태로 시작인증 준비 단계에서 로그인이 끝났는지 확인한 뒤 Context의 상태를 저장합니다.
await page.context().storageState({ path: "playwright/.auth/user.json" });로그인이 필요한 테스트에서는 저장한 인증 상태를 불러옵니다.
test.use({ storageState: "playwright/.auth/user.json" });
test("게시글 작성 화면에 접근할 수 있다", async ({ page }) => {
await page.goto("/posts/new");
await expect(
page.getByRole("heading", { name: "게시글 작성" }),
).toBeVisible();
});인증 파일은 테스트를 실행하기 전에 생성해야 합니다. setup project와 프로젝트 의존성으로 실행 순서를 보장할 수 있습니다. 같은 파일을 사용하더라도 각 테스트는 자신의 Context에 상태를 불러오므로 브라우저 환경의 격리는 유지됩니다.
storageState는 쿠키와 localStorage를 저장합니다. 인증이 IndexedDB에 의존한다면 상태를 저장할 때 indexedDB: true 옵션을 사용해야 합니다. sessionStorage는 기본 저장 대상이 아니므로 별도로 처리해야 합니다.
Playwright의 APIRequestContext를 사용하면 브라우저를 조작하지 않고 HTTP 요청을 보낼 수 있습니다. 검증 대상이 아닌 준비 과정을 API로 처리하면 테스트의 목적이 선명해져요.
예를 들어 게시글 수정 테스트에서 게시글 작성 화면부터 거칠 필요는 없습니다. API로 게시글을 만든 뒤, 수정 기능만 UI로 검증할 수 있습니다. 아래 코드는 page와 request Fixture를 받으며, 게시글 생성 API가 생성한 게시글의 id를 반환하고 DELETE /api/posts/:id로 게시글을 삭제할 수 있다고 가정합니다.
test("게시글 제목을 수정한다", async ({ page, request }) => {
const response = await request.post("/api/posts", {
data: { title: "수정 테스트용 게시글" },
});
expect(response.ok()).toBeTruthy();
const post = (await response.json()) as { id: string };
try {
await page.goto(`/posts/${post.id}/edit`);
await page.getByLabel("제목").fill("수정된 제목");
await page.getByRole("button", { name: "저장", exact: true }).click();
await expect(
page.getByRole("heading", { name: "수정된 제목" }),
).toBeVisible();
} finally {
await request.delete(`/api/posts/${post.id}`);
}
});생성 API의 응답에서 게시글 ID를 얻은 뒤, UI 검증을 try 블록에 둡니다. UI 조작이나 단언이 실패해도 finally 블록은 실행되므로 생성한 게시글의 삭제를 시도할 수 있습니다. 이런 준비와 정리 과정이 여러 테스트에서 반복된다면 사용자 정의 Fixture로 옮기는 편이 좋습니다.
기본 request Fixture는 브라우저에서 이후에 바뀐 쿠키를 자동으로 공유하지 않습니다. 브라우저 Context의 쿠키를 함께 사용해야 한다면 page.request나 context.request를 사용하세요.
Network Mocking은 브라우저의 요청을 가로채 원하는 응답을 반환하는 방식입니다. 실제 서버에서 재현하기 어려운 오류나 빈 응답을 만들어 화면의 대응을 검증할 수 있습니다.
아래 테스트는 게시글 조회 API가 500 상태 코드를 반환했을 때 오류 안내가 나타나는지 확인합니다. 요청이 발생하기 전에 가로채야 하므로 page.route()를 페이지 이동보다 먼저 등록합니다.
test("게시글 조회 실패 시 오류 안내를 보여준다", async ({ page }) => {
await page.route("**/api/posts", async (route) => {
await route.fulfill({
status: 500,
json: { message: "Internal Server Error" },
});
});
await page.goto("/posts");
await expect(page.getByText("잠시 후 다시 시도해주세요.")).toBeVisible();
});Mock 테스트는 정해진 응답에 대한 프론트엔드 동작만 검증합니다. 실제 백엔드와 올바르게 연동되는지까지 보장하지 않으므로 실제 서버를 사용하는 주요 흐름 테스트와 역할을 나눠야 합니다.
Projects는 같은 테스트를 서로 다른 설정으로 실행하는 단위입니다. 브라우저 종류뿐 아니라 모바일 화면 크기, 인증 상태, 접속 환경도 구분할 수 있습니다.
아래 설정은 Chromium과 Firefox에서 같은 테스트를 실행합니다. CI 환경에서는 실패한 테스트를 한 번 재시도하고, 첫 번째 재시도에서 Trace를 기록합니다. 테스트 대상 서버가 http://localhost:5173에서 실행되고 있다고 가정합니다.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
retries: process.env.CI ? 1 : 0,
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
});CI에서는 의존성과 브라우저를 설치하고, 서버와 테스트 데이터를 준비한 뒤 테스트를 실행해야 합니다. 실패 분석에 필요한 리포트와 Trace 파일도 보관하세요.
Trace Viewer는 테스트에서 수행한 Action, DOM 스냅샷, 네트워크 요청, 콘솔 메시지 등을 시간순으로 보여줍니다. 로컬에서는 성공하지만 CI에서 실패할 때 당시 상태를 확인하는 데 유용합니다.
npx playwright test
npx playwright show-trace path/to/trace.ziptrace: 'on-first-retry'는 첫 번째 재시도에서 Trace를 기록하므로 재시도 설정이 필요합니다. 위 설정은 CI에서만 재시도하므로 로컬 실패를 조사할 때는 Trace 설정을 별도로 바꿔야 합니다.
재시도는 실패 원인을 해결하지 않습니다. 테스트가 간헐적으로 실패한다면 고정 시간 대기, 테스트 간 데이터 공유, 불안정한 Locator부터 확인하세요. 병렬 실행을 늘리기 전에도 테스트 데이터가 충돌하지 않는지 검토해야 합니다.
Codegen은 브라우저에서 직접 수행한 행동을 Playwright 테스트 코드로 만들어 줍니다. 처음 테스트를 작성하거나 적절한 Locator를 찾을 때 초안으로 활용할 수 있습니다. UI Mode에서는 테스트를 선택해 실행하고, 실행 과정과 결과를 확인할 수 있습니다.
npx playwright codegen http://localhost:5173
npx playwright test --uiCodegen이 만든 코드를 그대로 완료된 테스트로 간주하면 안됩니다. 불필요한 동작을 덜어내고, Locator가 사용자의 관점을 반영하는지 확인한 뒤, 행동의 결과를 검증하는 Assertion을 보완해야 합니다.
Playwright를 학습할 때는 API 이름을 외우기보다 테스트가 실행되는 흐름을 이해하는 것이 중요합니다.
Fixture로 격리된 테스트 환경 준비
→ Page에서 화면 이동
→ Locator로 요소 지정
→ Auto-waiting 후 Action 수행
→ Web-first Assertion으로 결과 확인
→ 테스트 데이터와 자원 정리안정적인 E2E 테스트를 작성하기 위한 기준은 다음과 같습니다.
결국 안정적인 E2E 테스트는 브라우저를 조작하는 코드만으로 완성되지 않습니다. 테스트가 어떤 상태에서 시작하고, 무엇을 검증하며, 사용한 데이터와 자원을 어떻게 정리할지까지 함께 설계해야 합니다.