iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
自我挑戰組

Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記系列 第 7

Day 7 - 緩衝日①:後台 CRUD 流程實戰演練

  • 分享至 

  • xImage
  •  

經過前面 6 天的學習,今天我們會用目前學到的觀念(Playwright 基礎架構、Locator 定位策略、POM 頁面物件模式),挑戰一個貼近真實工作情境的完整測試演練!

1. 情境題目:後台商品 CRUD 流程實戰

在真實的後台管理系統(E-Commerce Admin)中,最常見的自動化測試情境就是 CRUD(Create, Read, Update, Delete) 流程。

今天的練習目標是針對 react-admin demo 主線網站,用 POM 架構完成以下完整的實戰演練:

  1. 登入後台:透過 LoginPage 登入。
  2. 導航與搜尋商品:進入商品列表頁面(Posters),進行搜尋與篩選。
  3. 新增商品 (Create):點擊 Create 按鈕,切換 Tab 標籤並填寫商品資料表單(名稱、價格、類別、縮圖等)後點擊 Save 保存。
  4. 驗證結果:確認新建立的商品資料成功顯示在商品列表中。

2. 技能 Checklist

在動手寫程式前,我們先檢核這項任務需要用到前面哪些學到的技術點:

  • [x] 環境啟動與 page 基本操作。
  • [x] 認識 BrowserContext 隔離與頁面狀態。
  • [x] 靈活運用多種 Locator 策略(getByRolegetByLabelgetByTextgetByTestId),解決 MUI 元件定位眉角。
  • [x] 使用 Page Object Model (POM) 模組化封裝頁面屬性與操作方法。

3. 實作記錄

為了維持結構清晰且易於維護,我們採用 POM 設計模式,本次會用到以下幾個 Page Object 類別:

  • LoginPage.ts (之前已建立)
  • ProductsPage.ts (商品列表頁,之前已建立,只需稍微擴充)
  • ProductCreatePage.ts (商品新增頁,本次新建)

(1) 擴充既有的商品列表頁 (ProductsPage.ts)

因為我們在先前的章節已經建立了 ProductsPage.ts,並實作了元件定位與如 gotosearch 等方法,所以這裡我們直接沿用,只需要針對本次 CRUD 流程,補充一些缺少的互動方法即可(例如:點擊新增按鈕 clickCreate、驗證商品列表 expectProductVisible)。下面就是這次完整的ProductsPage.ts程式碼:

// playwright-tests/pages/ProductsPage.ts
import { type Page, type Locator, expect } 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' });
        // 改用 test-id 進行穩定定位
        this.resultCount = page.getByTestId('product-result-count');
    }

    async goto() {
        await this.menuItem.click();
        // 確保成功進入商品列表
        await expect(this.page).toHaveURL(/.*#\/products/);
    }

    async search(keyword: string) {
        await this.searchInput.fill(keyword);
        // 按下 Enter 觸發搜尋
        await this.page.keyboard.press('Enter');
    }

    productCard(reference: string): Locator {
        return this.page.getByRole('link', {
            name: new RegExp(`^${reference}\\b`),
        });
    }

    // --- 以下為本次 CRUD 流程新增的方法 ---

    async clickCreate() {
        await this.createButton.click();
    }

    async expectProductVisible(productName: string) {
        // 驗證新增的商品標題出現在畫面上
        await expect(this.page.getByText(productName).first()).toBeVisible();
    }
}

💡 設計抉擇 1:使用 data-testid 取代正則表達式
原本在定位「商品總數 (result count)」時,可能會考慮使用 page.getByText(/^\d+-\d+ of \d+$/) 來抓取。雖然這符合使用者視角,但正則表達式稍顯脆弱且難以維護。為了讓測試更穩定,我們回到專案原始碼 demo/src/products/ProductList.tsx 中,找到原本的 <Pagination />,並加上 data-testid

// demo/src/products/ProductList.tsx
// ... 前略
                <Box
                    sx={{
                        width: isSmall ? 'auto' : 'calc(100% - 16em)',
                    }}
                >
                    <ImageList />
                    {/* 👇 直接在 Pagination 元件上加上 data-testid */}
                    <Pagination 
                        data-testid="product-result-count" 
                        rowsPerPageOptions={[12, 24, 48, 72]} 
                    />
                </Box>
// ... 後略

透過加上 data-testid="product-result-count",我們就能在 Page Object 中改用更穩定、不怕 UI 框架改版的 getByTestId 進行精準定位。

💡 設計抉擇 2:為什麼在 POM 裡面寫 expect
在前面的章節中,我們提過「不建議將斷言 (expect) 寫在 Page Object 內」,以免混淆測試邏輯與頁面操作。但在這裡我們破例在兩個地方使用了 expect,原因如下:

  1. goto():主要用來做「穩定性與前置狀態確認」。確保點擊選單後,URL 真的切換到了目標頁面,後續的元素操作才不會因為頁面切換的時間差而報錯。
  2. expectProductVisible():當某個斷言邏輯(例如「驗證商品是否出現在列表中」)會在多個不同的測試案例中被重複使用時,將其抽象化封裝在 POM 裡面,可以大幅提高測試腳本的簡潔度,方便未來跨測試複用。

(2) 商品新增頁 Page Object (ProductCreatePage.ts)

react-admin demo 中,建立商品的表單採用了 MUI 的 TabbedForm 結構,分為 Image、Details、Description 三個頁籤(Tab)。我們在 Page Object 中示範不同 Locator 策略的組合:

import { Page, Locator, expect } from '@playwright/test';

type ProductDetails = {
    reference: string;
    category: string;
    width: string;
    height: string;
    price: string;
    stock: string;
};

export class ProductCreatePage {
    readonly page: Page;
    // 表單欄位 Locator
    readonly imageInput: Locator;
    readonly thumbnailInput: Locator;
    readonly referenceInput: Locator;
    readonly categorySelect: Locator;
    readonly widthInput: Locator;
    readonly heightInput: Locator;
    readonly priceInput: Locator;
    readonly stockInput: Locator;
    readonly detailsTab: Locator;
    readonly saveButton: Locator;

    constructor(page: Page) {
      this.page = page;
      this.imageInput = page.getByRole('textbox', { name: 'Image' });
      this.thumbnailInput = page.getByLabel('Thumbnail');
      this.referenceInput = page.getByRole('textbox', { name: 'Reference' });
      this.categorySelect = page.getByRole('combobox', { name: 'Category' });
      this.widthInput = page.getByRole('spinbutton', { name: 'Width' });
      this.heightInput = page.getByRole('spinbutton', { name: 'Height' });
      this.priceInput = page.getByRole('spinbutton', { name: 'Price' });
      this.stockInput = page.getByRole('spinbutton', { name: 'Stock' });
      this.saveButton = page.getByRole('button', { name: 'Save' });
      this.detailsTab = page.getByRole('tab', { name: 'Details' });
    }

    // 填圖片
    async fillImages(url: string) {
      // 填寫 Image 分頁欄位
      await this.imageInput.fill(url);
      await this.thumbnailInput.fill(url);
    }

    // 填明細,第一行先切到 Details 分頁,測試不用知道有這回事
    async fillDetails(details: ProductDetails) {
      await this.detailsTab.click();
      await this.referenceInput.fill(details.reference);
      // Category 不是原生 select,要先 click() 展開再點 option
      await this.selectCategory(details.category);

      // Width / Height / Price / Stock 都是數字欄位,role 是 spinbutton
      await this.widthInput.fill(details.width);
      await this.heightInput.fill(details.height);
      await this.priceInput.fill(details.price);
      await this.stockInput.fill(details.stock);
    }

    async fillProductForm(url: string, data: ProductDetails) {
      // 1. 填寫 Image 分頁欄位
      await this.fillImages(url)

      // 2. 切換至 Details 分頁,填寫 Details 分頁欄位
      await this.fillDetails(data)
    }

    async selectCategory(categoryName: string) {
      // 點擊下拉選單展開
      await this.categorySelect.click();
      // MUI 選單選項Popover位於全局body,使用 getByRole('option') 點擊
      await this.page
          .getByRole('option', { name: categoryName, exact: true })
          .click();
    }

    async save() {
        await this.saveButton.click();
    }
}

(3) 整合測試腳本 (tests/crud-product.spec.ts)

// playwright-tests/tests/crud-product.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductsPage } from '../pages/ProductsPage';
import { ProductCreatePage } from '../pages/ProductCreatePage';

test.describe('後台商品 CRUD 流程測試', () => {

  test('成功建立一筆新商品並顯示在列表中', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const productsPage = new ProductsPage(page);
    const productCreatePage = new ProductCreatePage(page);

    const testProductName = `Awesome Poster ${Date.now()}`;

    // 1. 登入系統
    await loginPage.goto();
    await loginPage.login('demo', 'demo');

    // 2. 進入商品列表並點擊新增
    await productsPage.goto();
    await productsPage.clickCreate();

    // 3. 填寫新增表單
    await productCreatePage.fillProductForm('https://example.com/poster.jpg', {
        reference: testProductName,
        width: '10',
        height: '20',
        price: '29.99',
        stock: '10',
        category: 'animals',
    });

    // 4. 保存商品
    await productCreatePage.save();

    // 5. 搜尋並驗證資料成功顯示
    await productsPage.goto();
    await productsPage.search(testProductName);

    // 驗證列表中能看到該商品
    await productsPage.expectProductVisible(testProductName);
  });

});

(4) 執行測試

npx playwright test crud-product.spec.ts
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/crud-product.spec.ts:8:1 › 成功建立一筆新商品並顯示在列表中 (13.1s)

  1 passed (14.5s)

想看它實際跑在瀏覽器上的樣子,可以加上 --headed

npx playwright test crud-product.spec.ts --headed

🐛 真實踩雷:明明邏輯沒問題,最後一步卻一直失敗

執行上面的指令後,你會發現它居然每次都在最後一步 await productsPage.expectProductVisible(testProductName) 超時失敗(Timeout)。明明我們寫的測試流程跟手動操作一模一樣,為什麼 Playwright 還是會報錯說找不到元素呢?

如果使用 --headed 重新盯著畫面跑一次,會發現一個詭異的現象:它有正常執行搜尋,但畫面卻沒有停在搜尋結果頁,而是直接跳轉進了剛剛建立的那張 Poster 的資料編輯頁面,為什麼?

在回答這個問題前,我們先來介紹 Playwright 一個非常方便的功能——測試過程錄影 (Video Recording)。與其每次除錯都要土法煉鋼地加上 --headed 重新盯著畫面重跑一次,不如先在 playwright.config.ts 補上錄影設定,讓失敗的過程直接留下完整紀錄:

// playwright.config.ts
use: {
    baseURL: 'http://localhost:8000',
    video: 'retain-on-failure', // 👈 失敗時保留錄影,方便事後回放定位問題
    trace: 'on-first-retry',
},

有了 video: 'retain-on-failure' 這個設定,只要測試一失敗,Playwright 就會自動在瀏覽器跳出 HTML 報告。直接點進失敗的那筆測試,就能看到當時完整的錄影畫面,完全不用再手動盯著螢幕重跑一次。

透過錄影回放,我們再次確認了這個詭異的地方:畫面確實在按下 Save 之後跳回了搜尋結果列表,但也瞬間被彈回了「剛剛新增的那筆商品」自己的商品詳情(Edit)頁!但同樣的操作,我們手動操作一次卻完全正常。

簡單來說,這其實是一個「非同步網路請求」與「測試操作速度」之間的競速(Race Condition):

  1. 在實務的網頁應用中,點擊 Save 送出表單後,通常需要等待一段時間(例如 API 請求完成),系統才會進行下一步的「重新導向(Redirect)」到商品編輯頁。
  2. 但在原本的 save() 方法中,我們「一點擊 Save 按鈕」程式就馬上往下執行,並沒有等待非同步的重新導向完成
  3. 於是,測試腳本以極快的速度接連點擊了「Posters」選單、輸入搜尋字並按下 Enter。
  4. 此時,剛才點擊 Save 觸發的網路請求終於完成了!系統執行了原定的「導向至商品編輯頁」動作,直接把我們剛查好的搜尋結果頁面蓋掉。最後一步 expectProductVisible 在編輯頁面上找不到商品列表,自然就超時失敗了。

手動操作不會遇到這個問題,是因為人類移動滑鼠、打字的速度,通常比網路請求完成的時間還要慢,不知不覺中就「等」到了重新導向完成。

解法:在 save() 裡明確等待非同步的重新導向真正完成,再讓測試繼續往下走:

// playwright-tests/pages/ProductCreatePage.ts
async save() {
    await this.saveButton.click();
    // 必須等網址真的變成「商品詳情頁」,才代表表單送出且重新導向已完成。
    // 避免後續的測試步驟在導頁發生前就「搶跑」,反而被較晚觸發的導頁行為蓋掉畫面。
    await this.page.waitForURL(/#\/products\/\d+$/);
}

補上這行等待後,測試就穩定通過了。這個案例示範了一個很常見的實務狀況:當你的操作會觸發非同步行為(例如網路請求、頁面跳轉)時,最好的做法是必須明確等待該非同步動作結束,然後才繼續執行後續的操作或斷言,去確認結果是否符合你的預期。 這種不加等待就直接執行後續操作,然後依賴自動斷言(Auto-retrying assertions)的寫法,在測試環境中很容易因為執行速度差而變得不穩定。

小結

到今天為止,我們已經完成了階段一:入門與架構建立 (Day 1-7) 的全套學習!

回顧這 7 天的旅程,從一開始的環境建置與首支登入測試、了解 Browser/Context/Page 三層架構、體驗 Codegen 錄製、掌握各種 Locator 策略,再到這兩天實作 POM (Page Object Model) 設計模式、善用 data-testid 增強定位穩定度、結合錄影功能 (Video Recording) 除錯,以及最後學會處理自動化測試中最棘手的「非同步競速 (Race Condition)」問題。你已經為自己打下了撰寫高防禦力 E2E 測試腳本的堅實基本功!

下一階段:階段二:Fixture 與 Worker 機制 (Day 8-16),我們將進入 Playwright 最精髓、最強大的進階功能——包含進階 Locator 技巧、Worker機制、深入探討 expect 斷言,以及能夠徹底解救「重複登入」的 Fixture 機制


上一篇
Day 6 - POM 基礎:把登入包裝成 LoginPage,重構 Day 2 的測試
下一篇
Day 8 - 進階 Locator:Role-based、Chaining、Filtering
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言