從使用者操作到可驗證腳本:Playwright Test 核心能力

從使用者操作到可驗證腳本:Playwright Test 核心能力

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(Arrange/Act/Assert)Pattern 撰寫測試案例

​圖:使用 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適用情境
1getByRole()按鈕、連結、標題、輸入框等具語意元素
2getByLabel()表單欄位
3getByPlaceholder()缺少 label 的輸入欄位
4getByText()商品名稱、訊息與可見文字
5getByTestId()需要穩定且明確的測試識別
6locator()使用穩定的 nametype 等屬性
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 後執行測試

​圖:Playwright 測試總管啟用 Show Browser 後執行測試

不要急著把所有流程塞進一支測試

完整購物主流程可能包含:

登入
→ 商品列表
→ 分類與分頁
→ 搜尋商品
→ 商品詳情
→ 加入購物車
→ 折扣驗證
→ 修改數量
→ 結帳
→ 送出訂單
→ 訂單成立

若一開始就把所有步驟塞進單一測試,腳本會過長,失敗時也難以定位問題。

較合理的作法是先拆成:

  1. 登入後可以進入商品列表。
  2. 可以搜尋指定商品並進入商品詳情。
  3. 加入商品後可以看到正確小計與折扣。
  4. 修改數量後,折扣金額會依規則變化。
  5. 達到折扣門檻後可以完成結帳。

待各段流程穩定後,再依測試目的決定是否組合為一條核心 UI Smoke Test。

Smoke Test 的價值不在於步驟最多,而在於能以合理成本快速回答:

系統修改後,最重要的使用者流程是否仍然可以完成?

結語

Playwright 核心腳本能力可以整理為:

Navigation
→ Locator
→ User Actions
→ Assertions
→ Waiting / Auto-waiting
→ Test Structure

撰寫 UI 測試時,應先理解使用者操作流程,再決定如何定位、操作與驗證。

Locator 應盡量使用接近使用者視角的語意;Action 應對應真實操作;Assertion 應驗證使用者可見的業務結果,而不是只確認按鈕可以點擊。

當測試資料、前置狀態與重複流程也被適當整理後,Playwright 腳本才會從一次性的瀏覽器操作,轉變為可重複執行、可診斷、可維護的驗收規格。

留下第一條留言