---
title: Page Object Model 實戰 — 測試維護成本降 80% 的設計模式
description: POM 完整指南。為什麼用、怎麼拆 class、Playwright 實作範例、Component Object 進階、反模式。附類別關係圖與重構流程。
category: automation
tags: [page-object-model, playwright, design-pattern, 自動化, 重構]
date: 2026-06-10
---

# Page Object Model 實戰 — 測試維護成本降 80% 的設計模式

寫了 30 個 E2E test 之後，你會發現：UI 改一個 button class 名稱，**30 個 test 全要改**。Page Object Model（POM）就是解這個問題。這篇給你從零到上線的完整流程。

## 沒有 POM 的痛

```typescript
// test/login.spec.ts
test('login success', async ({ page }) => {
  await page.goto('https://app.example.com/login');
  await page.locator('input[name="email"]').fill('a@b.com');
  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('new@b.com');
  // ... 同樣 selector 又寫一次
});

// test/forgot.spec.ts
// ... 又一次
```

UI 把 `name="email"` 改成 `name="user_email"`，**3 個 test 都壞**。三十個 test 三十處要改。

## POM 後的世界

```typescript
// 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('a@b.com', 'xxx');
  await expect(page).toHaveURL('/dashboard');
});
```

UI 改了 → **只改 `LoginPage.ts` 一處**。30 個 test 自動全好。

## 三層架構

```mermaid
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

```typescript
// 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 用

```typescript
// 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('qa@test.com', 'Pass@123');
    await expect(page.getByText('Welcome')).toBeVisible();
  });

  test('wrong password shows error', async () => {
    await login.loginAndExpectError('qa@test.com', 'wrong', '帳號或密碼錯誤');
  });

  test('forgot password navigates correctly', async ({ page }) => {
    await login.forgotLink.click();
    await expect(page).toHaveURL('/forgot-password');
  });
});
```

## 一個 PR 的重構流程

從沒 POM → 有 POM，**不要一次改 50 個 test**。漸進式：

```mermaid
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 出現最多次：

```bash
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：

```typescript
// 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 都能組合它：

```typescript
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 讀起來像英文句：

```typescript
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 自動注入：

```typescript
// 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('a@b.com', '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 個月就會被棄置。**早一週做、省一年痛**。
