5. Playwright 브라우저 테스트
의미와 범위가 분명한 locator로 준비된 화면을 조작합니다. 조건과 이벤트로 기다리고 fixture와 page object는 실제 반복 경계에 맞춥니다.
5.1 의미와 범위가 분명한 locator를 사용합니다
DOM 순서와 서드파티 클래스 변경에 흔들리지 않고 의도한 요소를 조작합니다.
role과 접근 가능한 이름, label로 요소를 찾고 모달이나 구역 안으로 범위를 좁힙니다. 같은 이름이 여러 개면 정확한 이름과 소유 구역으로 구분하며 last나 nth로 모호함을 숨기지 않습니다.
순서 자체가 계약인 행과 셀에는 인덱스를 사용할 수 있습니다. 텍스트나 의미로 찾을 수 없는 canvas 등에는 명시적인 test id를 사용합니다. CSS 클래스와 좌표는 스타일이나 렌더 위치 자체를 검사할 때 필요한 범위에서 사용하고 일반 클릭의 기본 선택자로 삼지 않습니다. 키보드와 포커스가 계약인 요소는 해당 조작으로도 확인합니다.
참고: 공식 문서.
클래스와 마지막 버튼이라는 우연한 순서로 선택합니다.
import { expect, test } from "@playwright/test";
test("저장하면 완료 안내가 표시된다", async ({ page }) => {
/**
* GIVEN
* 주문 등록 모달이 열린 화면
*/
await page.goto("/orders/new");
/**
* WHEN
* 주문 저장
*/
await page.locator(".MuiDialog-root button").last().click();
/**
* THEN
* 저장 완료 안내
*/
await expect(page.getByRole("status")).toHaveText("저장했습니다");
});이름 붙은 모달 안에서 저장 버튼을 선택합니다.
import { expect, test } from "@playwright/test";
test("저장하면 완료 안내가 표시된다", async ({ page }) => {
/**
* GIVEN
* 주문 등록 모달이 열린 화면
*/
await page.goto("/orders/new");
/**
* WHEN
* 주문 저장
*/
await page
.getByRole("dialog", { name: "주문 등록" })
.getByRole("button", { name: "저장", exact: true })
.click();
/**
* THEN
* 저장 완료 안내
*/
await expect(page.getByRole("status")).toHaveText("저장했습니다");
});5.2 관찰 가능한 상태를 기다리고 이벤트 대기는 액션 전에 등록합니다
임의의 대기 시간과 이벤트 등록 순서로 생기는 불안정한 실패를 줄입니다.
화면 상태는 await expect와 locator의 재시도 가능한 matcher로 기다립니다. 요청, 다운로드, 팝업처럼 놓칠 수 있는 이벤트는 그 이벤트를 발생시키는 액션 전에 대기를 등록합니다.
| 필요한 확인 | 방법 |
|---|---|
| 표시, 문구, 개수, URL | 해당 locator나 page의 비동기 matcher |
| DOM 밖의 관측값 | 상한이 있는 expect.poll 등 조건 반복 |
| 준비 이벤트가 없는 canvas | 렌더 완료 신호나 안정된 관측 조건과 시간 상한 |
임의의 sleep, networkidle, force 옵션으로 준비 상태를 대신하지 않습니다. 안정화에 필요한 반복 간격과 제품의 지연 시간 검증은 구분하고 실패 시 관측값을 남깁니다.
참고: 공식 문서.
고정 시간 뒤에 한 번만 문구를 읽습니다.
import { expect, test } from "@playwright/test";
test("조회 후 목록이 표시된다", async ({ page }) => {
/**
* GIVEN
* 주문 목록의 진입 경로
*/
const path = "/orders";
/**
* WHEN
* 주문 조회 뒤 고정 시간 대기
*/
await page.goto(path);
await page.waitForTimeout(1000);
/**
* THEN
* 한 번 읽은 완료 문구
*/
expect(await page.getByRole("status").textContent()).toBe("조회했습니다");
});문구가 계약을 만족할 때까지 제한 시간 안에서 재시도합니다.
import { expect, test } from "@playwright/test";
test("조회 후 목록이 표시된다", async ({ page }) => {
/**
* GIVEN
* 주문 목록의 진입 경로
*/
const path = "/orders";
/**
* WHEN
* 주문 조회 화면 진입
*/
await page.goto(path);
/**
* THEN
* 조회 완료 상태까지 재시도하는 단언
*/
await expect(page.getByRole("status")).toHaveText("조회했습니다");
});액션 전에 등록한 대기로 다운로드 이벤트를 놓치지 않습니다.
import { expect, test } from "@playwright/test";
test("다운로드를 클릭하면 주문 파일을 받는다", async ({ page }) => {
/**
* GIVEN
* 다운로드할 주문 목록
*/
await page.goto("/orders");
/**
* WHEN
* 다운로드 대기를 먼저 등록한 뒤 버튼 클릭
*/
const pendingDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "다운로드", exact: true }).click();
const download = await pendingDownload;
/**
* THEN
* 다운로드한 주문 파일 이름
*/
expect(download.suggestedFilename()).toBe("orders.xlsx");
});5.3 공통 준비와 반복되는 화면 조작만 추출합니다
테스트의 핵심 시나리오를 숨기지 않고 중복되는 준비와 탐색을 줄입니다.
공통 세션과 자원 수명은 fixture로, 반복되는 locator와 화면 조작은 소유 화면의 page object로 묶습니다. 한 번 쓰는 단언이나 작은 동작마다 helper를 만들지 않습니다.
page object는 시나리오의 기대값을 숨기지 않고 테스트가 결과를 단언하게 합니다. fixture의 의존성 이름과 scope는 실제 사용 범위에 맞추고 테스트별 상태를 worker 전역으로 올리지 않습니다. Playwright가 매개변수 구조분해로 fixture 의존성을 읽는 콜백은 그 외부 호출 계약을 유지합니다. 다른 객체 필드까지 구조분해하는 근거로 넓히지 않습니다.
참고: 공식 문서.
테스트마다 같은 화면의 locator 체인을 반복합니다.
import { expect, test } from "@playwright/test";
test("목록에 주문이 표시된다", async ({ page }) => {
/**
* GIVEN
* 조회할 주문 목록
*/
const path = "/orders";
/**
* WHEN
* 주문 목록 진입
*/
await page.goto(path);
/**
* THEN
* 목록의 대표 주문
*/
await expect(page
.getByRole("table", { name: "주문 목록" })
.getByRole("row")
.filter({ hasText: "order-1" })).toBeVisible();
});
test("주문을 누르면 상세로 이동한다", async ({ page }) => {
/**
* GIVEN
* 대표 주문이 있는 목록
*/
await page.goto("/orders");
/**
* WHEN
* 대표 주문 링크 클릭
*/
await page
.getByRole("table", { name: "주문 목록" })
.getByRole("row")
.filter({ hasText: "order-1" }).getByRole("link").click();
/**
* THEN
* 선택한 주문의 상세 URL
*/
await expect(page).toHaveURL(/\/orders\/order-1$/);
});반복되는 화면 탐색을 page object fixture로 공유합니다.
import { expect, test as base } from "@playwright/test";
import type { Page } from "@playwright/test";
/**
* 주문 화면의 반복 탐색과 이동
*/
class OrdersPage {
constructor(private readonly page: Page) {}
get firstOrder() {
return this.page
.getByRole("table", { name: "주문 목록" })
.getByRole("row")
.filter({ hasText: "order-1" });
}
async goto() {
await this.page.goto("/orders");
}
}
/**
* 주문 화면 전용 fixture
*/
interface Fixtures {
/**
* 테스트가 받은 page를 사용하는 화면 객체
*/
ordersPage: OrdersPage;
}
const test = base.extend<Fixtures>({
ordersPage: async ({ page }, use) => {
await use(new OrdersPage(page));
},
});
test("목록에 주문이 표시된다", async ({ ordersPage }) => {
/**
* GIVEN
* 기본 fixture가 제공하는 order-1 주문과 화면 객체
*/
/**
* WHEN
* 주문 목록 진입
*/
await ordersPage.goto();
/**
* THEN
* 목록의 대표 주문
*/
await expect(ordersPage.firstOrder).toBeVisible();
});
test("주문을 누르면 상세로 이동한다", async ({ page, ordersPage }) => {
/**
* GIVEN
* 대표 주문이 있는 목록
*/
await ordersPage.goto();
/**
* WHEN
* 대표 주문 링크 클릭
*/
await ordersPage.firstOrder.getByRole("link").click();
/**
* THEN
* 선택한 주문의 상세 URL
*/
await expect(page).toHaveURL(/\/orders\/order-1$/);
});5.4 사전조건을 직접 준비하고 검증할 여정은 실제로 수행합니다
불필요한 탐색을 줄이면서 라우팅과 상태 전달의 연결을 실제로 검증합니다.
검증 전 사전조건은 fixture, API, 딥링크로 직접 준비할 수 있습니다. 검증 대상인 클릭, 이동, URL 상태 전달은 실제 UI와 앱 진입점을 통과합니다.
목록에서 상세로 이동하는 계약이면 상세 URL로 바로 여는 것으로 대신하지 않습니다. 서버 로그인 자체가 대상이 아니면 매 테스트마다 로그인 UI를 반복하지 않습니다. 뒤로가기, 새로고침, 지속성이 대상이면 그 과정에서 필요한 저장소를 지우거나 라우터를 새로 만들지 않습니다. 통합 테스트는 필요한 실제 provider와 라우터 계약을 포함하고 자체 harness가 대신하는 경계를 기록합니다.
참고: 공식 문서.
상세 URL로 바로 열어 목록 링크의 연결을 건너뜁니다.
import { expect, test } from "@playwright/test";
test("목록의 주문 링크로 상세에 진입한다", async ({ page }) => {
/**
* GIVEN
* 목록에서 선택할 주문 ID
*/
const orderId = "order-1";
/**
* WHEN
* 링크를 거치지 않고 상세 직접 진입
*/
await page.goto(`/orders/${orderId}`);
/**
* THEN
* 상세 화면의 제목
*/
await expect(
page.getByRole("heading", { name: "주문 order-1" }),
).toBeVisible();
});사전조건인 목록 URL부터 검증할 링크로 이동합니다.
import { expect, test } from "@playwright/test";
test("목록의 주문 링크로 상세에 진입한다", async ({ page }) => {
/**
* GIVEN
* 수량 정렬된 주문 목록
*/
await page.goto("/orders?sort=quantity");
/**
* WHEN
* 목록의 주문 링크 클릭
*/
await page.getByRole("link", { name: "주문 order-1", exact: true }).click();
/**
* THEN
* 선택한 주문의 상세 화면
*/
await expect(
page.getByRole("heading", { name: "주문 order-1" }),
).toBeVisible();
});