
UI 自動化測試 - 使用 Playwright x AI Agent 系列 #03
UI 自動化測試的重點,不是讓程式模擬滑鼠點擊,而是把一段使用者操作流程,轉換為可重複執行、可判定通過或失敗,並能持續維護的測試腳本。
Playwright Test 提供的核心能力可整理為一條固定路徑:
開啟頁面
→ 定位元素
→ 執行操作
→ 驗證結果
→ 處理等待
→ 整理測試結構
本文使用一個寵物購物系統作為案例,示範登入、商品搜尋、商品詳情、購物車與折扣金額驗證。QA 開發人員可將系統視為黑箱(Black-box),不需理解內部程式實作,而是從使用者操作界面、可觀察行為與預期結果出發,分析並撰寫 Playwright 測試腳本。
重點不在於特定購物系統本身,而是理解如何將使用者操作流程轉換為具備明確驗證條件的 Playwright Test。
從人工操作轉換為測試腳本
以登入流程為例,人工操作通常會描述為:
開啟登入頁
→ 輸入帳號
→ 輸入密碼
→ 點擊登入
→ 確認進入商品列表頁
這段操作轉為 Playwright Test 後,可以寫成:
import { test, expect } from '@playwright/test';
test('有效帳號可以登入並進入商品列表', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('username-input').fill('user1');
await page.getByTestId('password-input').fill('user1');
await page.getByTestId('login-submit').click();
await expect(
page.getByRole('heading', { name: '寵物用品' })
).toBeVisible();
await expect(
page.getByTestId('current-user')
).toHaveText('user1');
});
這段測試包含四個基本區段:
| 區段 | Playwright 語法 | 用途 |
|---|---|---|
| 定義測試 | test(...) | 定義一個可執行的測試案例 |
| 開啟頁面 | page.goto() | 進入測試起點 |
| 操作畫面 | fill()、click() | 模擬使用者操作 |
| 驗證結果 | expect(...) | 判定測試是否通過 |

圖:人工流程對應到腳本步驟
這裡最重要的差異是:人工操作通常只描述「做了什麼」,測試腳本還必須定義「什麼結果才算正確」。
使用 3A Pattern 組織測試結構與驗證意圖
Playwright 測試可使用 3A Pattern 整理:
Arrange:準備測試狀態
Act:執行使用者操作
Assert:驗證使用者可見結果
例如商品搜尋測試:
test('使用者可以搜尋犬糧商品', async ({ page }) => {
// Arrange:登入並進入商品列表
await page.goto('/login');
await page.getByTestId('username-input').fill('user1');
await page.getByTestId('password-input').fill('user1');
await page.getByTestId('login-submit').click();
// Act:執行搜尋
await page.getByLabel('搜尋商品').fill('犬糧');
await page.getByRole('button', { name: '搜尋' }).click();
// Assert:驗證搜尋結果
await expect(
page.getByText('活力雞肉犬糧')
).toBeVisible();
});

圖:使用 3A Pattern 撰寫測試案例
AAA 並不是 Playwright 強制語法,而是一種讓測試意圖更清楚的組織方式。當測試失敗時,也較容易判斷問題發生在前置狀態、操作步驟或驗證結果。
TypeScript 在 Playwright 測試中的角色
Playwright Test 可使用 JavaScript 或 TypeScript。TypeScript 在 UI 測試中的主要價值,是提供型別檢查、編輯器提示與較佳的維護性,而不是用來建立前端應用程式。
常見結構如下:
import { test, expect, type Page } from '@playwright/test';
test 用於定義測試案例,expect 用於結果驗證,Page 則可用於 helper function 的型別標註。
async function login(page: Page) {
// 共用登入流程
}
Playwright Test 會在測試執行時提供 page fixture,代表目前測試使用的瀏覽器頁面,因此不需要在每支測試中自行建立 browser、context 與 page。
Navigation:確認測試是否進入正確頁面
Navigation 不只是呼叫 page.goto(),還包含確認頁面是否到達可操作狀態。
若 playwright.config.ts 已設定 baseURL:
use: {
baseURL: 'http://127.0.0.1:8765',
}
測試中即可使用相對路徑:
await page.goto('/login');
頁面開啟後,應驗證一個可觀察的狀態:
await expect(
page.getByRole('button', { name: '登入' })
).toBeVisible();
登入成功後,也可驗證 URL:
await expect(page).toHaveURL(/.*products/);
但 URL 不一定是最重要的判準。若系統使用相同 URL 呈現不同狀態,應優先驗證使用者可見內容,例如頁面標題、目前登入者、商品名稱或完成訊息。
await expect(
page.getByRole('heading', { name: '寵物用品' })
).toBeVisible();
Locator:找到元素不代表定位方式合理
Locator 決定測試如何找到欄位、按鈕、連結、商品卡片與提示訊息。
Playwright 測試不應只追求「目前找得到元素」,而應選擇在 UI 小幅調整後仍能維持穩定的定位方式。
常見 Locator 包括:
page.getByRole('button', { name: '登入' });
page.getByLabel('搜尋商品');
page.getByPlaceholder('請輸入商品名稱');
page.getByText('活力雞肉犬糧');
page.getByTestId('username-input');
page.locator('input[name="username"]');
建議優先順序如下:
| 優先順序 | Locator | 適用情境 |
|---|---|---|
| 1 | getByRole() | 按鈕、連結、標題、輸入框等具語意元素 |
| 2 | getByLabel() | 表單欄位 |
| 3 | getByPlaceholder() | 缺少 label 的輸入欄位 |
| 4 | getByText() | 商品名稱、訊息與可見文字 |
| 5 | getByTestId() | 需要穩定且明確的測試識別 |
| 6 | locator() | 使用穩定的 name、type 等屬性 |
| 7 | 結構型 CSS 或 XPath | 無其他可靠線索時的最後手段 |
先縮小範圍,再操作目標元素
商品列表中可能存在多個「查看」連結。下列寫法可能點擊第一個匹配項目,而不是指定商品:
await page.getByRole('link', { name: '查看' }).click();
較合理的作法,是先定位商品卡片,再操作卡片內的連結:
const productCard = page
.getByTestId('product-card')
.filter({ hasText: '活力雞肉犬糧' });
await productCard
.getByRole('link', { name: '查看' })
.click();
這種寫法保留了明確的操作意圖:不是點擊任意「查看」,而是開啟指定商品的詳情頁。
避免容易失效的 Selector
下列 Selector 過度依賴 DOM 位置、CSS class 或完整結構:
page.locator('div:nth-child(3) > button');
page.locator('.btn.btn-primary.mt-2');
page.locator('#app > main > section > div > div:nth-child(2)');
只要畫面重新排版、CSS class 調整或外層增加容器,測試就可能失效。
較好的寫法應描述使用者操作意圖:
await page
.getByRole('button', { name: '加入購物車' })
.click();
Locator 品質會直接影響測試穩定性。測試腳本應保護使用者行為,而不是綁定當下的 HTML 排版方式。
User Actions:模擬使用者實際操作
常見 User Actions 包括:
await page.getByRole('button', { name: '登入' }).click();
await page.getByLabel('搜尋商品').fill('犬糧');
await page.getByLabel('商品分類').selectOption('dog');
await page.getByRole('checkbox', { name: '只顯示有庫存商品' }).check();
搜尋框若支援 Enter,也可以使用:
const searchBox = page.getByLabel('搜尋商品');
await searchBox.fill('犬糧');
await searchBox.press('Enter');
測試操作應盡量符合真實使用方式。若使用者會點擊搜尋按鈕,就測試 click();若正式流程支援 Enter,就測試 press('Enter')。
測試不應為了方便而改走使用者不會使用的捷徑,否則即使測試通過,也未必能證明實際 UI 流程可用。
Assertions 設計原則
Action 只代表操作已執行,不代表功能正確。
例如:
await page
.getByRole('button', { name: '加入購物車' })
.click();
這行只能證明 Playwright 成功點擊按鈕,不能證明商品真的加入購物車,更不能證明數量、折扣與總金額正確。
購物車流程應進一步驗證:
await expect(
page.getByText('活力雞肉犬糧')
).toBeVisible();
await expect(
page.getByTestId('cart-subtotal')
).toHaveText('NT$ 1360');
await expect(
page.getByTestId('cart-discount')
).toHaveText('NT$ 136');
await expect(
page.getByTestId('cart-total')
).toHaveText('NT$ 1224');
不同操作可對應不同的業務結果:
| 不足的驗證 | 較完整的驗證 |
|---|---|
| 登入按鈕可點擊 | 登入後顯示商品列表與目前使用者 |
| 搜尋欄可以輸入文字 | 搜尋結果出現指定商品 |
| 加入購物車按鈕可點擊 | 購物車顯示商品、數量與金額 |
| 前往結帳按鈕可點擊 | 結帳頁顯示訂單摘要與正確總額 |
| 送出訂單按鈕可點擊 | 顯示訂單成立訊息與訂單金額 |
Assertion 的目的,是證明使用者真正關心的結果仍然成立。
Auto-waiting:等待機制與 timeout 使用原則
Playwright 在執行操作前,會自動檢查元素是否可見、穩定、可接收事件,並確認沒有被其他元素遮住。這些機制稱為 auto-waiting 與 actionability checks。
因此,多數測試不需要自行加入固定等待:
await page.waitForTimeout(3000);
固定等待有兩個問題:
- 頁面若 1 秒完成,測試仍會浪費 3 秒。
- 頁面若超過 3 秒才完成,測試仍會失敗。
較好的方式是等待明確狀態:
await expect(
page.getByText('活力雞肉犬糧')
).toBeVisible();
或等待頁面切換與標題出現:
await expect(page).toHaveURL(/.*cart/);
await expect(
page.getByRole('heading', { name: '購物車' })
).toBeVisible();
Timeout 也不應用來掩蓋不穩定測試。測試經常 timeout 時,應先檢查:
- 服務是否已啟動。
- URL 或 port 是否正確。
- Locator 是否符合目前 UI。
- 前一步操作是否成功。
- Assertion 是否符合實際畫面。
- 測試資料是否受到前一次執行影響。
只有在明確知道某個操作確實需要較長時間時,才調整 timeout。
測試案例的命名原則
測試案例不應只是描述操作:
test('click checkout button', async ({ page }) => {
// ...
});
較好的名稱應直接表達驗證目標:
test('購物車達滿額門檻時可以看到 9 折後總額', async ({ page }) => {
// ...
});
測試名稱應回答:
這個測試要證明什麼行為仍然成立?
當測試報告顯示失敗時,具體名稱也能讓開發者直接理解受影響的功能,而不必先閱讀整支腳本。
集中管理測試資料
測試資料若散落在操作與 Assertion 中,後續很難判斷各數值的來源。
可先集中整理:
const testUser = {
username: 'user1',
password: 'user1',
};
const targetProduct = {
keyword: '犬糧',
name: '活力雞肉犬糧',
unitPriceText: 'NT$ 680',
quantityForDiscount: '2',
subtotalWithDiscountText: 'NT$ 1360',
discountText: 'NT$ 136',
totalWithDiscountText: 'NT$ 1224',
};
當商品價格、測試帳號或折扣規則調整時,就能清楚知道哪些預期值需要同步修改。
測試資料的整理方式可依規模選擇:
| 資料規模 | 建議方式 | 適用情境 |
|---|---|---|
| 少量固定資料 | 測試檔或 helper 檔案 | 單一 Smoke Test、少量帳號與商品 |
| 中量測試資料 | JSON 或 CSV | 多組登入、搜尋與預期結果 |
| 大量或需重設狀態 | DB seed data | 商品、使用者、訂單與初始狀態 |
DB seed data 可用來建立固定前置條件,但不能取代 UI 驗證。Playwright 測試仍應從使用者可見畫面判斷商品、金額、錯誤訊息與流程結果是否正確。
使用 Helper Function 整理重複操作
當多支測試都需要登入、搜尋商品或進入商品詳情頁,可將重複操作抽成 helper function。
async function login(page: Page) {
await page.goto('/login');
await page
.getByTestId('username-input')
.fill(testUser.username);
await page
.getByTestId('password-input')
.fill(testUser.password);
await page
.getByTestId('login-submit')
.click();
await expect(
page.getByRole('heading', { name: '寵物用品' })
).toBeVisible();
}
搜尋商品也可整理為:
async function searchProduct(
page: Page,
keyword: string,
productName: string,
) {
await page
.getByLabel('搜尋商品')
.fill(keyword);
await page
.getByRole('button', { name: '搜尋' })
.click();
await expect(
page.getByRole('heading', { name: productName })
).toBeVisible();
}
Helper function 應保持短小,並封裝具備明確語意的重複操作。若過度包裝,測試案例本身反而會失去可讀性。
另外,不應讓測試案例彼此呼叫。多個案例若共用前置流程,應抽成 helper function 或放入 beforeEach(),讓每個 test(...) 仍可獨立執行。
建立最小購物流程測試
將登入、清空購物車、搜尋商品、開啟商品詳情與金額驗證整合後,可形成下列測試:
import { test, expect, type Page } from '@playwright/test';
const testUser = {
username: 'user2',
password: 'user2',
};
const product = {
keyword: '犬糧',
name: '活力雞肉犬糧',
quantity: '2',
subtotalText: 'NT$ 1360',
discountText: 'NT$ 136',
totalText: 'NT$ 1224',
};
async function login(page: Page) {
await page.goto('/login');
await page
.getByTestId('username-input')
.fill(testUser.username);
await page
.getByTestId('password-input')
.fill(testUser.password);
await page
.getByTestId('login-submit')
.click();
await expect(
page.getByRole('heading', { name: '寵物用品' })
).toBeVisible();
}
async function clearCart(page: Page) {
await page.goto('/cart');
while (
await page.getByRole('button', { name: /移除/ }).count()
) {
await page
.getByRole('button', { name: /移除/ })
.first()
.click();
}
}
test(
'使用者可以搜尋商品並在加入購物車後看到滿額折扣',
async ({ page }) => {
await login(page);
await clearCart(page);
await page.goto('/products');
await page
.getByLabel('搜尋商品')
.fill(product.keyword);
await page
.getByRole('button', { name: '搜尋' })
.click();
const productCard = page
.getByTestId('product-card')
.filter({ hasText: product.name });
await productCard
.getByRole('link', { name: '查看' })
.click();
await page
.getByLabel('數量')
.fill(product.quantity);
await page
.getByRole('button', { name: '加入購物車' })
.click();
await expect(
page.getByRole('heading', { name: '購物車' })
).toBeVisible();
await expect(
page.getByTestId('cart-subtotal')
).toHaveText(product.subtotalText);
await expect(
page.getByTestId('cart-discount')
).toHaveText(product.discountText);
await expect(
page.getByTestId('cart-total')
).toHaveText(product.totalText);
},
);
這支測試同時示範:
Navigation
→ Locator
→ User Actions
→ Assertions
→ Auto-waiting
→ Test Data
→ Helper Function
測試執行前先清空購物車,是為了避免前一次執行殘留資料影響金額。這也說明 UI 測試的穩定性不只取決於腳本語法,還取決於可控制的測試前置狀態。
觀察測試實際操作瀏覽器
Playwright Test 預設使用 headless 模式。若需要觀察實際操作,可加入 --headed:
npx playwright test tests/core-scripts/petshop_product_flow.spec.ts --project=chromium --headed
Playwright Test 預設使用 headless(無頭)模式,也就是測試執行時不顯示瀏覽器視窗,適合快速執行與自動化驗證。Headed(有頭)模式則會開啟可見的瀏覽器視窗,方便觀察實際操作流程、檢查 Locator 與分析測試失敗原因。
若要逐步檢查 Locator 與操作流程,可使用 debug 模式:
npx playwright test tests/core-scripts/petshop_product_flow.spec.ts --project=chromium --debug
Debug 模式會開啟 Playwright Inspector,可逐步查看操作、Locator 與測試狀態。
使用 VS Code 的 Playwright Test 擴充套件時,也可在 Testing 面板啟用 Show Browser,直接觀察測試執行過程。

圖:Playwright 測試總管啟用 Show Browser 後執行測試
不要急著把所有流程塞進一支測試
完整購物主流程可能包含:
登入
→ 商品列表
→ 分類與分頁
→ 搜尋商品
→ 商品詳情
→ 加入購物車
→ 折扣驗證
→ 修改數量
→ 結帳
→ 送出訂單
→ 訂單成立
若一開始就把所有步驟塞進單一測試,腳本會過長,失敗時也難以定位問題。
較合理的作法是先拆成:
- 登入後可以進入商品列表。
- 可以搜尋指定商品並進入商品詳情。
- 加入商品後可以看到正確小計與折扣。
- 修改數量後,折扣金額會依規則變化。
- 達到折扣門檻後可以完成結帳。
待各段流程穩定後,再依測試目的決定是否組合為一條核心 UI Smoke Test。
Smoke Test 的價值不在於步驟最多,而在於能以合理成本快速回答:
系統修改後,最重要的使用者流程是否仍然可以完成?
結語
Playwright 核心腳本能力可以整理為:
Navigation
→ Locator
→ User Actions
→ Assertions
→ Waiting / Auto-waiting
→ Test Structure
撰寫 UI 測試時,應先理解使用者操作流程,再決定如何定位、操作與驗證。
Locator 應盡量使用接近使用者視角的語意;Action 應對應真實操作;Assertion 應驗證使用者可見的業務結果,而不是只確認按鈕可以點擊。
當測試資料、前置狀態與重複流程也被適當整理後,Playwright 腳本才會從一次性的瀏覽器操作,轉變為可重複執行、可診斷、可維護的驗收規格。