Page Object Model 實戰 — 測試維護成本降 80% 的設計模式
寫了 30 個 E2E test 之後,你會發現:UI 改一個 button class 名稱,30 個 test 全要改。Page Object Model(POM)就是解這個問題。這篇給你從零到上線的完整流程。
沒有 POM 的痛
// test/login.spec.ts
test('login success', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.locator('input[name="email"]').fill('[email protected]');
await page.locator('input[name="password"]').fill('xxx');
await page.locator('button.btn-submit').click();
await expect(page).toHaveURL('/dashboard');
});
// test/register.spec.ts
test('register flow', async ({ page }) => {
await page.goto('https://app.example.com/register');
await page.locator('input[name="email"]').fill('[email protected]');
// ... 同樣 selector 又寫一次
});
// test/forgot.spec.ts
// ... 又一次
UI 把 name="email" 改成 name="user_email",3 個 test 都壞。三十個 test 三十處要改。
POM 後的世界
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
emailInput = this.page.getByLabel('Email');
passwordInput = this.page.getByLabel('Password');
submitButton = this.page.getByRole('button', { name: '登入' });
async goto() { await this.page.goto('/login'); }
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// test/login.spec.ts
test('login success', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('[email protected]', 'xxx');
await expect(page).toHaveURL('/dashboard');
});
UI 改了 → 只改 LoginPage.ts 一處。30 個 test 自動全好。
三層架構
flowchart TD
Tests["🧪 Tests Layer<br>describe / test"] --> Pages["📄 Page Objects<br>LoginPage / CheckoutPage"]
Pages --> Components["🧩 Component Objects<br>NavBar / Modal / Toast"]
Components --> Locators["🎯 Locators<br>getByRole / getByLabel"]
Locators --> DOM["🌐 Real DOM"]
style Tests fill:#06b6d4,stroke:#06b6d4,color:#fff
style Pages fill:#10b981,stroke:#10b981,color:#fff
style Components fill:#a855f7,stroke:#a855f7,color:#fff
style Locators fill:#f59e0b,stroke:#f59e0b,color:#fff
style DOM fill:#374151,stroke:#9ca3af,color:#fff
各層責任:
| 層 | 該做 | 不該做 |
|---|---|---|
| Tests | 描述「使用者要幹嘛」 | 直接寫 selector |
| Page Objects | 封裝整頁的操作 | 寫 business assertion |
| Component Objects | 重複出現的 UI 區塊 | 跨頁的邏輯 |
| Locators | 找 DOM 元素 | 任何邏輯 |
從零建一個 LoginPage
Step 1: 列頁面元素
打開頁面,列出 test 會用到的 element:
- Email 輸入框
- Password 輸入框
- 「記住我」checkbox
- 「登入」按鈕
- 「忘記密碼」連結
- 錯誤訊息區(登入失敗時)
Step 2: 寫 class skeleton
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
// Locators — 在 constructor 內定義一次、後面重用
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly rememberMe: Locator;
readonly submitButton: Locator;
readonly forgotLink: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.rememberMe = page.getByLabel('記住我');
this.submitButton = page.getByRole('button', { name: '登入' });
this.forgotLink = page.getByRole('link', { name: '忘記密碼' });
this.errorMessage = page.locator('[data-testid="login-error"]');
}
// Navigation
async goto() {
await this.page.goto('/login');
await expect(this.submitButton).toBeVisible();
}
// Actions
async login(email: string, password: string, remember = false) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
if (remember) await this.rememberMe.check();
await this.submitButton.click();
}
async loginAndExpectSuccess(email: string, password: string) {
await this.login(email, password);
await this.page.waitForURL('**/dashboard');
}
async loginAndExpectError(email: string, password: string, errorText: string) {
await this.login(email, password);
await expect(this.errorMessage).toContainText(errorText);
}
}
Step 3: 在 test 用
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('Login', () => {
let login: LoginPage;
test.beforeEach(async ({ page }) => {
login = new LoginPage(page);
await login.goto();
});
test('successful login', async ({ page }) => {
await login.loginAndExpectSuccess('[email protected]', 'Pass@123');
await expect(page.getByText('Welcome')).toBeVisible();
});
test('wrong password shows error', async () => {
await login.loginAndExpectError('[email protected]', 'wrong', '帳號或密碼錯誤');
});
test('forgot password navigates correctly', async ({ page }) => {
await login.forgotLink.click();
await expect(page).toHaveURL('/forgot-password');
});
});
一個 PR 的重構流程
從沒 POM → 有 POM,不要一次改 50 個 test。漸進式:
flowchart LR
A["1️⃣ 找重複度<br>最高的頁面"] --> B["2️⃣ 建第一個<br>Page Object"]
B --> C["3️⃣ 改 1-2 個 test<br>跑通 CI"]
C --> D["4️⃣ 其他 test<br>逐步遷移"]
D --> E["5️⃣ 寫團隊<br>convention 文件"]
E --> F["6️⃣ Code review<br>強制套 POM"]
style A fill:#06b6d4,color:#fff
style B fill:#06b6d4,color:#fff
style C fill:#10b981,color:#fff
style D fill:#10b981,color:#fff
style E fill:#a855f7,color:#fff
style F fill:#f59e0b,color:#fff
找重複度最高的頁面:用 grep 看哪個 selector 出現最多次:
grep -rn "getByLabel('Email')" tests/ | wc -l # 23 次
grep -rn "input[name=\"email\"]" tests/ | wc -l # 8 次
23 次的 → 第一個建 POM。
Component Object — 跨頁重複的 UI
NavBar、Footer、Modal、Toast 這些多頁共用的,獨立成 Component Object:
// components/NavBar.ts
export class NavBar {
constructor(private page: Page) {}
userMenu = this.page.getByRole('button', { name: /使用者選單/ });
cartButton = this.page.getByRole('button', { name: /購物車/ });
cartBadge = this.page.locator('[data-testid="cart-count"]');
async openUserMenu() { await this.userMenu.click(); }
async logout() {
await this.openUserMenu();
await this.page.getByRole('menuitem', { name: '登出' }).click();
}
async cartCount(): Promise<number> {
const text = await this.cartBadge.textContent();
return parseInt(text || '0');
}
}
任何 Page Object 都能組合它:
export class DashboardPage {
readonly navBar: NavBar;
constructor(public page: Page) {
this.navBar = new NavBar(page);
}
}
// test
test('cart count after add', async ({ page }) => {
const dashboard = new DashboardPage(page);
await dashboard.navBar.cartButton.click();
expect(await dashboard.navBar.cartCount()).toBe(3);
});
進階:Fluent Interface(鏈式)
讓 test 讀起來像英文句:
export class CheckoutPage {
// ...
async selectPayment(method: 'credit' | 'paypal') {
await this.page.getByLabel(method).check();
return this; // 回傳 this 才能鏈式
}
async fillCard(number: string, exp: string, cvc: string) {
await this.cardNumberInput.fill(number);
await this.expInput.fill(exp);
await this.cvcInput.fill(cvc);
return this;
}
async submit() {
await this.submitButton.click();
return this;
}
}
// test 可以寫成
await new CheckoutPage(page)
.selectPayment('credit')
.fillCard('4111111111111111', '12/30', '123')
.submit();
讀起來像真的「結帳流程」。
Fixtures 整合(Playwright 專屬)
進階做法 — 用 Playwright fixture 把 Page Object 自動注入:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { CheckoutPage } from './pages/CheckoutPage';
type MyFixtures = {
loginPage: LoginPage;
checkoutPage: CheckoutPage;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => { await use(new LoginPage(page)); },
checkoutPage: async ({ page }, use) => { await use(new CheckoutPage(page)); },
});
export { expect } from '@playwright/test';
// test 直接用
import { test, expect } from './fixtures';
test('login', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('[email protected]', 'xxx');
});
new LoginPage() 都不用寫,code 更乾淨。
目錄結構建議
e2e/
├── fixtures.ts
├── pages/
│ ├── LoginPage.ts
│ ├── CheckoutPage.ts
│ ├── ProductDetailPage.ts
│ └── DashboardPage.ts
├── components/
│ ├── NavBar.ts
│ ├── Footer.ts
│ ├── ProductCard.ts
│ └── Toast.ts
├── flows/ # 跨多頁的完整流程
│ └── PurchaseFlow.ts
├── data/
│ ├── users.ts # test user 資料
│ └── products.ts
└── tests/
├── auth.spec.ts
├── checkout.spec.ts
└── ...
反模式(千萬不要這樣)
-
Page Object 內塞 assert
typescript // ❌ async login(email, pwd) { await this.emailInput.fill(email); await this.submitButton.click(); await expect(this.page).toHaveURL('/dashboard'); // ❌ 不該在這裡 }POM 負責「操作」、assert 留在 test。否則 reuse 困難。 -
太薄 — 每個 method 只 wrap 一個 click
typescript // ❌ async clickSubmit() { await this.submitButton.click(); } // 這沒有抽象價值POM 要封裝「業務行為」(login)、不是「單一動作」(clickSubmit)。 -
太胖 — 一個 class 1000 行 單頁太大就拆 Component Object。
-
跨頁共用同一個 Page Object
MainPage含 nav + product + footer + checkout → 1500 行。一頁一 class。 -
POM 內塞 sleep
typescript await this.page.waitForTimeout(2000); // ❌永遠等狀態不等時間。 -
selector 寫死在 method 內
typescript async login(email) { await this.page.getByLabel('Email').fill(email); // ❌ 應抽到 constructor }一改要改多處。
POM vs Screenplay Pattern
進階話題:Screenplay Pattern(Serenity BDD 派)— 用「演員-任務-能力」模型,比 POM 更細。新團隊不建議,學習成本高、收益對小團隊不明顯。先把 POM 做好再說。
ROI 觀察
維護成本對比(實測一個 50 test 的中型 app):
| 沒 POM | 有 POM | |
|---|---|---|
| 一次 UI 改動修改處 | 8-12 處 | 1 處 |
| 加新 test 時間 | 30 min | 8 min |
| Onboarding 新人寫 test | 1 週才上手 | 1 天 |
| Flaky 比例 | 高(selector 雜亂) | 低 |
第一週投資抽 POM、之後一年每週省 4 小時。複利效應強。
重構檢查清單
把 sprint 內一個小重構 task 變成 POM PR:
- [ ] 選一頁(搜尋出現最多 selector 的)
- [ ] 建
pages/XxxPage.ts - [ ] 1-2 個 test 改用 POM、跑通 CI
- [ ] 加 README 寫團隊 convention
- [ ] 開 issue 列剩下要改的 test、排 sprint
- [ ] PR template 加「新 test 要用 POM」
最後
POM 不是教科書理論、是長期 E2E 自動化能不能活下去的決定性因素。沒有 POM 的 E2E 撐不過 6 個月就會被棄置。早一週做、省一年痛。