在 Day 5 的測試程式中我們用 beforeEach 把重複的登入邏輯搬出測試本身,但如果我們開啟了另一個新的測試檔案,同樣的登入程式碼是不是又要再複製貼上一次?
為了解決這類跨檔案重複使用的痛點,讓我們來學習 Playwright 自動化測試中最經典的架構之一:POM(Page Object Model),並帶大家親手把登入流程封裝成 LoginPage。
beforeEach 與 POM 的差別beforeEach 負責「何時執行」:它是 Playwright Test 提供的 hook,用來讓同一個檔案裡的每支測試在執行前自動跑一次前置動作。不過它的作用範圍僅限於當前檔案,沒辦法跨檔案共用。import 重複利用的工具。這兩者並非二選一,而是可以互相搭配。今天的目標是先帶大家實作出這個「可以跨檔案 import 的登入模組」。至於要怎麼讓每支測試自動取得這個模組並保持登入狀態,我們會在後續介紹 Fixture 時再深入探討。
簡單來說,POM(Page Object Model)就是:把「頁面上有哪些元素」跟「在頁面上可以做什麼事」包裝成一個 Class,而測試檔案只負責呼叫這些方法並寫下斷言。
以登入頁面為例:
當我們把這些細節封裝成一個 LoginPage Class 之後,我們在測試腳本裡就只會看到類似 loginPage.login('demo', 'demo') 這樣簡潔的語法。
在測試專案中導入 POM 架構,主要有以下三個好處:
選擇器(Locator)集中管理:
如果在未來的某一天,PM 決定把登入按鈕的文案從 Sign in 改成 Log in。只要有了 POM,你只需要去 LoginPage 裡修改那一行的 locator,所有用到登入的幾十支測試就能一口氣全部修好。這是 POM 在後續維護上最直接的價值。
測試腳本讀起來更貼近真實使用者行為:loginPage.login('demo', 'demo') 讓人一看就知道是在執行登入;反觀 page.getByRole('textbox', { name: 'Username' }).fill('demo') 則是在描述 DOM 節點的操作細節。測試腳本應該讓人能很容易看懂「這支測試要驗證什麼」,而不是「這支測試點擊了哪些網頁元素」。
跨檔案的邏輯重複使用:
透過封裝,登入的邏輯只會存在於專案中的一個地方,未來再有新的測試檔案需要登入,直接 import 進來用就可以了。
在 playwright-tests 底下新增一個 pages 資料夾,跟 tests 同一層:
playwright-tests/
├── pages/
│ └── LoginPage.ts ← 今天新增
├── tests/
│ ├── login.spec.ts
│ └── dashboard.spec.ts
└── playwright.config.ts
LoginPage.ts 的內容:
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByRole('textbox', { name: 'Username' });
this.passwordInput = page.getByRole('textbox', { name: 'Password' });
this.signInButton = page.getByRole('button', { name: 'Sign in' });
}
async goto() {
await this.page.goto('/');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
這段程式碼有幾個重點:
constructor 接收 page:每支測試都會拿到一個由 Playwright 建立的全新分頁(page)。Page Object 本身不負責開啟瀏覽器,而是接收測試傳入的 page 來操作。這也是為什麼每支測試都需要 new LoginPage(page)。readonly 屬性:將屬性設為 readonly 能防止在後續操作中意外覆寫 Locator,確保測試碼的穩定性。此外,這裡常會有個疑問:在 new LoginPage(page) 時頁面根本還沒打開,這時先呼叫 getByRole(...) 不會報錯嗎?.fill() 或 .click() 時才會去尋找元素,所以放在 constructor 裡預先建好完全沒問題。login(username, password),我們用它來描述「登入」這個行為,而不是「填兩個欄位然後點擊按鈕」。這讓測試碼讀起來更直觀。goto() 的路徑設為 /:為了避免將完整網址寫死在各個測試檔中,我們可以統一在 playwright.config.ts 設定 baseURL:export default defineConfig({
use: {
baseURL: 'http://localhost:8000',
},
});
設定好之後,所有的 page.goto() 只需要給相對路徑,Playwright 就會自動幫我們補上前面的網址。未來如果需要切換測試環境,也只要改這一個地方就好。
先看 Day 2 的原始版本:
import { test, expect } from '@playwright/test';
test('測試標題', async ({ page }) => {
// 1. 開啟登入頁
await page.goto('http://localhost:8000/');
// 2. 填入帳號密碼
await page.getByRole('textbox', { name: 'Username' }).fill('demo');
await page.getByRole('textbox', { name: 'Password' }).fill('demo');
// 3. 點擊登入
await page.getByRole('button', { name: 'Sign in' }).click();
// 4. 驗證登入成功
await expect(
page.getByRole('heading', {
name: 'Welcome to the react-admin e-commerce demo',
})
).toBeVisible();
});
用 LoginPage 改寫之後:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('使用者可以登入後台', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
await expect(
page.getByRole('heading', {
name: 'Welcome to the react-admin e-commerce demo',
})
).toBeVisible();
});
原本的版本我們需要依賴註解才能知道每一段程式碼的意圖,而導入 POM 後,因為我們將細節的 DOM 操作封裝起來,並把操作描述成有意義的使用者行為,即使沒有註解,也能一眼就知道測試流程:進入登入頁、使用 demo/demo 登入、確認畫面出現歡迎訊息。
接下來我們進行第二個練習,來寫商品列表的測試,並順便建立 ProductsPage,看看不同頁面的 POM 長什麼樣子。
// pages/ProductsPage.ts
import { type Page, type Locator } from '@playwright/test';
export class ProductsPage {
readonly page: Page;
readonly menuItem: Locator;
readonly searchInput: Locator;
readonly createButton: Locator;
readonly resultCount: Locator;
constructor(page: Page) {
this.page = page;
this.menuItem = page.getByRole('menuitem', { name: 'Posters' });
this.searchInput = page.getByRole('textbox', { name: 'Search' });
this.createButton = page.getByRole('link', { name: 'Create' });
this.resultCount = page.getByText(/^\d+-\d+ of \d+$/);
}
async goto() {
await this.menuItem.click();
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
}
productCard(reference: string): Locator {
return this.page.getByRole('link', {
name: new RegExp(`^${reference}\\b`),
});
}
}
有兩個地方跟 LoginPage 不太一樣。
goto() 透過點擊側邊選單切換到商品列表頁。 雖然可以直接 page.goto('/#/products'),但點擊選單更貼近真實使用者的操作路徑。Page Object 的方法名稱可以固定都叫 goto,至於底下的實作方式則可以依據該頁面的實際情況來決定。
productCard() 方法回傳 locator。 因為要抓哪一張卡片得看參數,沒辦法在 constructor 就決定,所以回傳 Locator,而不是 Promise,這裡它只是提供找到畫面上元素的方法,但還沒去頁面上找,所以呼叫的時候不用 await。這是 Page Object 裡很常見的一種寫法,測試拿到之後可以自己決定要對這個元素做操作或是進行測試斷言。
resultCount 抓的是列表下方的分頁文字。
接下來直接在測試中使用 ProductsPage:
// scratch.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductsPage } from '../pages/ProductsPage';
test('搜尋商品後,列表只顯示符合的結果', async ({ page }) => {
const loginPage = new LoginPage(page);
const productsPage = new ProductsPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
await productsPage.goto();
const totalBeforeSearch = await productsPage.resultCount.innerText();
await productsPage.search('beach');
await expect(productsPage.resultCount).not.toHaveText(totalBeforeSearch);
await expect(productsPage.productCard('Beach Gazebo')).toBeVisible();
});
這裡有一個小提醒:用「搜尋前的筆數」作為基準,來斷言搜尋後的筆數不一樣,而不是把數字寫死(如 toHaveText('1-3 of 3'))。因為測試資料每次都會重新產生,寫死數字容易導致測試失敗。這種寫法也能利用 expect 自動重試的機制,達到「等待搜尋結果」的效果,比使用 waitForTimeout 更穩定。
最後,我們可以執行看看測試結果:
npx playwright test scratch.spec.ts
執行結果會顯示測試成功:
Running 3 tests using 3 workers
3 passed (17.8s)
To open last HTML report run:
npx playwright show-report
前面在寫 LoginPage 時,你可能會想加上斷言,例如expect(page.getByRole('button', { name: 'Sign in' })).toBeDisabled(),這樣測試裡面的程式不就更簡潔了嗎?
但其實並不建議這麼做,原因如下:
將 locator 開放給測試使用是完全沒問題的(例如前面的 productsPage.resultCount)。讓 POM 負責「找出元素」,讓測試負責「驗證結果」,這樣的界線既清楚又實用。
今天把登入邏輯到處重複的問題用 Page Object Model 把登入頁包裝成了 LoginPage,把商品列表封裝成了 ProductsPage,讓我們的測試檔案從原本繁瑣的「描述 DOM 操作」進化成了「描述有意義的使用者行為」。
關於 POM,最核心的原則其實是:測試檔案不應該出現 locator。只要你在測試腳本裡看到出現了 getByRole(...) 之類的語法,就是一個可以考慮往 Page Object 搬移的訊號。
下一篇是第一個緩衝日,我們會把 Day 2 到今天學的東西整合起來,用 POM 架構實際跑一次完整的後台 CRUD 流程:搜尋商品列表、建立一筆新商品、確認它真的出現在列表裡。
回頭看看我們今天改寫後的測試腳本,雖然登入步驟已經被簡化,但每支測試一開頭還是得出現這三行呼叫:
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
這代表如果有幾十支測試,這三行不僅會不斷重複出現,更麻煩的是每支測試都得從頭重跑一次真實的登入流程。有沒有辦法可以讓測試「直接拿到一個已經登入好的頁面」,連這三行程式碼都可以省下來呢?
當然有,我們後續會學習的 Fixture 機制可以解決這個問題!