페이지 객체 모델 (Playwright + TypeScript, 4장)
출처: Dev.to
Chapter 3에서는 위치를 찾고(assert) 깔끔하게 하는 방법을 배웠습니다. 하지만 Chapter 2의 로그인 테스트를 보면 셀렉터와 단계가 여전히 테스트 안에 들어가 있죠. 로그인된 사용자가 필요한 테스트를 다섯 개 작성한다면 그 블록을 다섯 번 복사하게 됩니다. 로그인 폼을 바꾸면 다섯 번이나 수정해야 합니다.
Page Object Model (POM) 은 이 문제를 해결합니다: 화면마다 하나의 클래스를 두어 해당 화면의 로케이터와 사용자가 할 수 있는 행동을 캡슐화합니다. 테스트는 이제 행동 중심으로 기술됩니다.
이번 장의 코드는 레포지토리에서 ch-04 태그가 붙어 있습니다:
https://github.com/aktibaba/playwright-qa-course — src/pages/LoginPage.ts 와 src/tests/ui/login.spec.ts 를 확인하세요.
Page Object 만들기
Page Object는 단순히 클래스일 뿐입니다. 생성자에 page 를 받아 로케이터를 필드로 노출하고, 동작을 메서드로 제공합니다:
// src/pages/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test";
export interface Credentials {
email: string;
password: string;
}
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(private readonly page: Page) {
this.email = page.getByPlaceholder("Email");
this.password = page.getByPlaceholder("Password");
this.submit = page.getByRole("button", { name: "Login" });
}
async goto(): Promise {
await this.page.goto("/#/login");
await expect(this.submit).toBeVisible();
}
async submitCredentials({ email, password }: Credentials): Promise {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
async loginAs(credentials: Credentials): Promise {
await this.goto();
await this.submitCredentials(credentials);
}
}
Enter fullscreen mode
Exit fullscreen mode
디자인 선택을 살펴보세요:
- 로케이터는 한 번만 정의됩니다. 생성자 안에서만 페이지 마크업을 알기 때문에 여기서 정의합니다.
- 메서드 이름은 의도(
loginAs)를 반영하고, 구현 세부 사항은 감춥니다. goto()가 폼이 준비될 때까지 기다리므로 호출자는 별도로 기다릴 필요가 없습니다.- 순수 TypeScript이며,
Credentials인터페이스 덕분에 호출부가 스스로 문서화됩니다.
사용법: 행동 중심 테스트
// src/tests/ui/login.spec.ts
import { test, expect, request as apiRequest } from "@playwright/test";
import { LoginPage } from "@pages/LoginPage";
import { env } from "@utils/env";
const SEED_USER = { email: "playwright@test.io", password: "Password123!" };
test.describe.configure({ mode: "serial" });
test.describe("Login (Page Object)", () => {
test.beforeAll(async () => {
const ctx = await apiRequest.newContext();
await ctx.post(`${env.apiURL}/test/reset`); // 시드 유저가 존재하도록 보장
await ctx.dispose();
});
test("a seeded user can log in", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.loginAs(SEED_USER);
await expect(page.getByRole("link", { name: "New Article" })).toBeVisible();
await expect(
page.getByRole("navigation").getByText("playwright"),
).toBeVisible();
});
});
Enter fullscreen mode
Exit fullscreen mode
이제 테스트 본문은 세 줄로 읽기 쉬워졌습니다. @pages/LoginPage 임포트가 동작하는 이유는 Chapter 2에서 설정한 paths 별칭 덕분이며, ../../../ 같은 복잡한 경로가 필요 없습니다.
어설션은 어디에 두나요?
유용한 규칙: Page Object는 페이지를 모델링하고, 테스트는 주장(어설션)을 만든다.
비즈니스 결과에 대한 expect(예: New Article이 보이면 로그인 성공) 는 테스트에 두고, 동작이 완료됐는지 확인하는 준비 대기 정도는 메서드 내부(goto()가 폼 렌더링을 검증) 에 포함해도 됩니다.
실제 문제: 테스트 데이터와 프로젝트 간 레이스 컨디션
이 테스트는 실제 시드 유저로 로그인하기 때문에, 시드 데이터가 존재해야 합니다. 그래서 beforeAll 에서 reset 을 호출합니다. 하지만 api 프로젝트도 데이터베이스를 리셋하는데, UI 테스트가 로그인 중일 때 API 리셋이 발생하면 로그인에 간헐적으로 실패합니다(예: 10번 중 7번 실패).
당분간의 임시 방편은 ui 프로젝트를 api 프로젝트가 끝난 뒤에 실행하도록 순서를 강제하는 것입니다:
// playwright.config.ts (ui project)
{
name: "ui",
testDir: "./src/tests/ui",
dependencies: ["api"], // api가 끝난 뒤 UI 테스트가 시작됨
use: { baseURL: env.webURL, ...devices["Desktop Chrome"] },
}
Enter fullscreen mode
Exit fullscreen mode
이 방법은 두 프로젝트 전체를 직렬화해 데이터 레이스를 회피하는 거친 도구입니다.
올바른 해결책은 각 테스트마다 고유한 격리된 데이터(API를 통해 생성한 새로운 사용자)를 제공하는 것이며, 이는 Part 4에서 구현합니다. 여기서는 문제를 보여주기 위해 임시 방편을 남겨 둡니다.
POM이 가져다 준 이점
- 한 곳에서 수정: 로그인 폼 마크업은
LoginPage에만 존재합니다. - 읽기 쉬운 테스트:
loginAs(user)은 무엇을 하는지 말해 주며, 어떻게 하는지는 감춥니다. - 재사용성: 로그인된 사용자가 필요한 모든 향후 테스트는 이 메서드 하나만 호출하면 됩니다.
하지만 아직 new LoginPage(page) 를 직접 만들고, 테스트 안에 SEED_USER 와 DB 리셋 로직을 하드코딩하고 있습니다. 이것이 다음 단계에서 해결할 도미노입니다.
다음 내용
Chapter 5 — 폼, 테이블, 다이얼로그: 페이지 오브젝트 모델을 활용해 더 복잡한 상호작용(아티클 에디터, 태그 입력, 확인 다이얼로그)을 구현하고, 작은 페이지 오브젝트 패밀리를 확장합니다. 이후 Part 2에서는 이들을 fixture 로 전환해 사용을 더욱 간편하게 만들 예정입니다. 태그: ch-05.
따라서 진행 중이신가요? 레포를 ⭐️ star 해 주시고, 여러분은 Page Object 안에 어설션을 넣는 편인가요, 아니면 테스트에 두는 편인가요? 라는 질문에 답변을 남겨 주세요.