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
    └── ...

反模式(千萬不要這樣)

  1. 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 困難。

  2. 太薄 — 每個 method 只 wrap 一個 click typescript // ❌ async clickSubmit() { await this.submitButton.click(); } // 這沒有抽象價值 POM 要封裝「業務行為」(login)、不是「單一動作」(clickSubmit)。

  3. 太胖 — 一個 class 1000 行 單頁太大就拆 Component Object。

  4. 跨頁共用同一個 Page Object MainPage 含 nav + product + footer + checkout → 1500 行。一頁一 class

  5. POM 內塞 sleep typescript await this.page.waitForTimeout(2000); // ❌ 永遠等狀態不等時間。

  6. 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 個月就會被棄置。早一週做、省一年痛