iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
自我挑戰組

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

Day 21 - AI 輔助生成測試:如何與AI協作產生高品質測試

  • 分享至 

  • xImage
  •  

在自動化測試的學習歷程中,許多人第一次嘗試使用 AI(如 Claude、ChatGPT)幫忙寫 Playwright 測試時,最常採用的方式就是直接把網址或簡單的一句需求丟給 AI:

「幫我寫 react-admin demo 網站 Reviews 頁面的評論狀態篩選測試。」

這個做法無法確保生成出來的程式碼品質,甚至可能實作出跑了但會壞掉的測試。AI 可能會自己手動寫了一段 page.goto('/login') 重新登入、隨便猜測了一個根本不存在的 .MuiInput-root > input CSS 定位點,甚至寫出了 page.waitForTimeout(5000) 這類在 Playwright 中應極力避免的寫法。

實際上比較好的做法是:「先確認 Test Case 與專案結構規範,最後才讓 AI 生成測試程式碼」。

本篇我們將深入探討這套分階段的 AI 輔助測試工作流,並說明如何結合 Test Case 發想、架構規範(從無到有生成 POM 與 Fixture)以及人工審查機制,產出高品質、可長期維護的 Playwright 測試。

為什麼直接叫 AI 生成測試容易失敗?

當我們只給 AI 一個網址或一句簡短需求時,AI 會面臨兩個關鍵的「上下文盲區」:

  1. 缺乏測試規格盲區(Test Case Blindness):AI 不知道這項功能的邊界條件是什麼、前置條件為何、以及「成功」的精準定義是什麼。
  2. 缺乏專案架構盲區(Architecture Blindness):AI 可能不知道或是不會特別去注意你的專案已經封裝了 loggedInPage fixture,也不知道團隊習慣將頁面操作與元素定位整理在 Page Object Model (POM) 中。

當上下文不足時,AI 就會盲猜。它會根據訓練資料中的萬用範例,寫出高度耦合、重複發明輪子且不夠可靠的測試腳本。

為了克服這個問題,我們需要將 AI 輔助測試拆解為 3 階段:

[階段 1:需求與 Test Case 發想] ➔ AI 輔助產生 Test Case,明確測試的內容
         ↓
[階段 2:結構與規範對齊]        ➔ 告知/確認專案的架構規範 (POM、Fixture、Locator 慣例)
         ↓
[階段 3:測試程式碼生成與審查]   ➔ 根據定案的 Test Case 與結構,產出高品質 Test Code

階段 1:用 AI 輔助生成與整理 Test Case

撰寫自動化測試的第一步,不是打開 VS Code 寫程式碼,而是釐清要測試什麼。在思考測試情境時,AI 會是很棒的助手,這邊可以由你自己先列出一些 test case 後請 AI 根據專案補強,或是全權交由 AI 處理都可以。

餵給 AI 的 Prompt 範例(發想 Test Case)

我正在為一個後台管理系統(react-admin)的 Reviews(評論管理)頁面設計自動化測試。
該頁面具備以下功能:
1. 頂部包含「Add filter」篩選按鈕,可新增 Status 或 Product 等篩選條件。
2. 頂部包含「Columns」工具按鈕,可動態開啟或隱藏表格特定欄位。
3. 列表表格包含 Date, Customer, Product, Rating, Comment, Status 等欄位。

請幫我列出此頁面值得測試的 Test Cases(涵蓋狀態篩選、特定商品篩選與欄位顯示切換),格式請包含:
- Test Case ID
- 前置條件 (Preconditions)
- 測試步驟 (Steps)
- 預期結果 (Expected Results)

AI 產出的結構化 Test Case 範例

經過 AI 整理後,我們會得到類似下表的結構化測試規格,後續我們會根據這個 test case 規劃來生成對應的程式碼:

Test Case ID 情境 前置條件 測試步驟 預期結果
TC01 依狀態篩選 Accepted 評論 已登入並進入 Reviews 頁面 1. 點擊 "Add filter" 新增 Status 篩選2. 選擇 "Accepted"3. 等待列表更新 列表資料筆數大於 0,且所有資料列的 Status 欄位均顯示 'accepted'。
TC02 依指定 Product 篩選評論 已登入並進入 Reviews 頁面 1. 點擊 "Add filter" 新增 Product 篩選2. 選擇商品下拉選項3. 等待列表更新 列表資料筆數大於 0,且所有資料列的 Product 欄位均包含該商品名稱。
TC03 隱藏 Customer 表格欄位 已登入並進入 Reviews 頁面 1. 點擊 "Columns" 工具按鈕2. 取消勾選 "Customer" 欄位 表格標頭中不再出現 "Customer" 欄位。

階段 2:說明結構規範,讓 AI 生成 POM 與 Fixture

有了明確的 Test Case 之後,接著要告訴 AI 「我們專案的遊戲規則是什麼」。

每支團隊對測試的檔案結構都有不同的架構偏好,我們需要在請 AI 寫測試前,先確定好這個結構,再請 AI 依照這個結構生成測試需要的 locator、page object 跟 fixture。這樣不僅能維持專案規範的一致性,也能 讓 AI 根據指定的架構約束,替我們產生高品質、規範統一的 POM 類別與屬性。

餵給 AI 的 Prompt 範例(生成全新的 POM)

請根據提供的 test case 規劃,為 Reviews 頁面建立 Page Object Model (POM)。

專案架構規範如下:
1. 請建立 TypeScript 類別 `ReviewsPage`,放在 `pages/ReviewsPage.ts`。
2. constructor 接收 `page: Page`。
3. 元素定位請優先採用 Playwright 語意化 API(如 `getByRole`),避免使用易碎的 CSS selector。
4. 請根據測試內容,生成 locator 及 POM 中必要的 method

請幫我生成完整的 ReviewsPage 程式碼。
(貼上上面的 test case 規劃)

AI 產出的全新 POM 程式碼 (ReviewsPage.ts)

根據我們給予的架構規範,AI 生成了乾淨且符合 TypeScript 型別的 Page Object:

// playwright-tests/pages/ReviewsPage.ts
import { type Page, type Locator, expect } from '@playwright/test';

/** Reviews 列表頁的 Page Object(涵蓋 TC01 狀態篩選 / TC02 商品篩選 / TC03 欄位顯示切換) */
export class ReviewsPage {
  readonly page: Page;

  readonly addFilterButton: Locator;
  readonly columnsButton: Locator;
  readonly statusSelect: Locator;
  readonly productSelect: Locator;
  readonly datagrid: Locator;
  readonly rows: Locator;

  constructor(page: Page) {
    this.page = page;

    this.addFilterButton = page.getByRole('button', { name: 'Add filter' });
    this.columnsButton = page.getByRole('button', { name: 'Columns' });

    // 避免與 Table Header 的 "Sort by status" 或 "Sort by product" 衝突,使用 combobox role
    this.statusSelect = page.getByRole('combobox', { name: 'Status' });
    this.productSelect = page.getByRole('combobox', { name: 'Product' });

    this.datagrid = page.getByRole('table');
    // 排除表頭列,只保留資料列
    this.rows = this.datagrid
      .getByRole('row')
      .filter({ hasNot: page.getByRole('columnheader') });
  }

  async goto(): Promise<void> {
    await this.page.goto('/#/reviews');
    await expect(this.datagrid).toBeVisible();
  }

  /** 包住會觸發列表重新查詢的操作,等待對應 API 回應與 Progressbar 結束 */
  private async waitForList(action: () => Promise<void>): Promise<void> {
    const response = this.page.waitForResponse(
      (res) => /\/reviews(\?|$)/.test(res.url()) && res.request().method() === 'GET'
    );
    await action();
    await response;
    await expect(this.page.getByRole('progressbar')).toHaveCount(0);
  }

  /** TC01: 依審核狀態篩選評論(例如:'Accepted', 'Pending', 'Rejected') */
  async filterByStatus(status: 'Accepted' | 'Pending' | 'Rejected'): Promise<void> {
    if (!(await this.statusSelect.isVisible())) {
      await this.addFilterButton.click();
      await this.page.getByRole('menuitemcheckbox', { name: 'Status' }).or(this.page.getByRole('menuitem', { name: 'Status' })).click();
    }

    await this.waitForList(async () => {
      await this.statusSelect.click();
      await this.page.getByRole('option', { name: status, exact: true }).click();
    });
  }

  /** TC02: 新增並選取指定 Product 篩選 (若未指定 productName,預設觸發 Autocomplete 選取第一項) */
  async filterByProduct(productName?: string): Promise<string> {
    if (!(await this.productSelect.isVisible())) {
      await this.addFilterButton.click();
      await this.page.getByRole('menuitemcheckbox', { name: 'Product' }).or(this.page.getByRole('menuitem', { name: 'Product' })).click();
    }

    // 點擊 Product 搜尋框並填入文字觸發 Autocomplete 下拉選單
    await this.productSelect.click();
    await this.productSelect.fill(productName || 'a');

    const firstOption = this.page.getByRole('option').first();
    await expect(firstOption).toBeVisible();

    const selectedName = (await firstOption.innerText()).trim();

    // 選取選項時會觸發 /reviews API 查詢
    await this.waitForList(async () => {
      await firstOption.click();
    });

    return selectedName;
  }

  /** TC03: 點選 Columns 按鈕並切換隱藏特定欄位 */
  async toggleColumn(columnName: string): Promise<void> {
    await this.columnsButton.click();
    const checkbox = this.page.getByRole('checkbox', { name: columnName });
    await expect(checkbox).toBeVisible();
    await checkbox.click();
    // 按 Escape 鍵關閉 Columns 下拉選單 popover
    await this.page.keyboard.press('Escape');
  }

  /** 取得目前表格中所有的 Column Header 名稱 */
  async getColumnHeaders(): Promise<string[]> {
    const headers = await this.datagrid.getByRole('columnheader').allInnerTexts();
    return headers.map((h) => h.trim());
  }

  /** 取得指定欄位的整欄文字 */
  async getColumnTexts(columnName: string): Promise<string[]> {
    const headers = await this.getColumnHeaders();
    const index = headers.findIndex((h) => h.includes(columnName));
    if (index === -1) {
      throw new Error(`Column "${columnName}" not found in headers: ${headers.join(', ')}`);
    }
    // ⚠️ 這行藏了一個 bug,測試照樣會通過,詳見下方「人工審查 Check 4」
    return (await this.rows.getByRole('cell').nth(index).allInnerTexts()).map((t) => t.trim());
  }

  async getStatusValues(): Promise<string[]> {
    const texts = await this.getColumnTexts('Status');
    return texts.map((t) => t.toLowerCase());
  }

  async getRowCount(): Promise<number> {
    return this.rows.count();
  }
}

[!TIP]
在 AI 產出 POM 後,我們可以先檢視 Locator 的寫法,如果無法判斷也可以等到寫 test code 的時候再調整。若頁面有特殊的 data-testid 或 Accessibility 結構,可在這個步驟先做微調,確保 POM 的基底穩固。

階段 3:結合定案的 Test Case 與全新的 POM,生成測試程式碼

現在我們有了:

  1. 階段 1 定案的 Test Case (TC01: Accepted 狀態篩選, TC02: Product 篩選, TC03: 隱藏 Customer 欄位)。
  2. 階段 2 產出 POM (ReviewsPage) 與既有的連線 Fixture (loggedInPage)。

在這個時間點讓 AI 生成測試腳本,AI 就有了極度明確的上下文,比較不會寫出混亂的程式碼。

餵給 AI 的 Prompt 範例(生成最終測試)

我們現在有了定案的 TC01、TC02 與 TC03 測試規格,以及在階段 2 剛生成的 ReviewsPage 與專案的 loggedInPage fixture。

請幫我撰寫最終的 TypeScript 測試檔案 `day21-reviews-ai.spec.ts`。
需求規範:
1. 使用 `loggedInPage` fixture 取得已登入頁面,不要重複執行登入流程。
2. 使用階段 2 產出的 `ReviewsPage` 物件進行導航、狀態篩選、商品篩選與欄位顯示切換。
3. 實作 TC01 (Accepted 狀態篩選)、TC02 (Product 篩選) 與 TC03 (隱藏 Customer 欄位)。
4. 採用 Web-First Assertions 與標準斷言來驗證資料列內容與欄位標頭。

AI 產出的最終測試程式碼

// playwright-tests/tests/day21-reviews-ai.spec.ts
import { test, expect } from '../fixtures/fixtures';
import { ReviewsPage } from '../pages/ReviewsPage';

test.describe('Day21 - Reviews 評論列表 AI 生成測試', () => {
  test('TC01: 依狀態篩選 Accepted,列表應只顯示已通過審核的評論', async ({ loggedInPage }) => {
    const reviewsPage = new ReviewsPage(loggedInPage);

    // 1. 前置條件與導航
    await reviewsPage.goto();

    // 2. 執行篩選:選取 "Accepted" 狀態
    await reviewsPage.filterByStatus('Accepted');

    // 3. 預期一:列表資料列不為空
    const rowCount = await reviewsPage.getRowCount();
    expect(rowCount).toBeGreaterThan(0);

    // 4. 預期二:所有資料列的 Status 欄位都必須為 'accepted'
    const statusValues = await reviewsPage.getStatusValues();
    const allAccepted = statusValues.every((status) => status === 'accepted');
    expect(allAccepted).toBe(true);
  });

  test('TC02: 新增 Product 篩選條件並隨便選擇一個商品,列表應只顯示該商品的評論', async ({ loggedInPage }) => {
    const reviewsPage = new ReviewsPage(loggedInPage);

    // 1. 前置條件與導航
    await reviewsPage.goto();

    // 2. 新增 Product 篩選並選擇下拉選單的第一個商品選項
    const selectedProductName = await reviewsPage.filterByProduct();

    // 3. 預期一:列表資料列不為空
    const rowCount = await reviewsPage.getRowCount();
    expect(rowCount).toBeGreaterThan(0);

    // 4. 預期二:列表中每一筆資料的 Product 欄位都必須為該商品
    const productTexts = await reviewsPage.getColumnTexts('Product');
    const allMatch = productTexts.every((text) => text.includes(selectedProductName));
    expect(allMatch).toBe(true);
  });

  test('TC03: 點選 Columns 按鈕關閉 Customer 欄位,畫面上應不再顯示該欄位', async ({ loggedInPage }) => {
    const reviewsPage = new ReviewsPage(loggedInPage);

    // 1. 前置條件與導航
    await reviewsPage.goto();

    // 2. 確認初始狀態下 Customer 欄位存在
    let headers = await reviewsPage.getColumnHeaders();
    expect(headers.some((h) => h.includes('Customer'))).toBe(true);

    // 3. 點選 Columns 並關閉 Customer 欄位
    await reviewsPage.toggleColumn('Customer');

    // 4. 預期結果:畫面表格標頭中不再包含 Customer 欄位
    headers = await reviewsPage.getColumnHeaders();
    expect(headers.some((h) => h.includes('Customer'))).toBe(false);
  });
});

人工審查 Checklist (Human Review Checkpoints)

即使 AI 在我們嚴格約束的 3 階段中完成產出,工程師仍需擔任最後一道品質防線:

flowchart TD
    A[AI 產出測試腳本] --> B{Check1: 架構合規性}
    B -- 否: 重複寫了登入 --> B1[修正: 改用 loggedInPage 與 ReviewsPage]
    B -- 是 --> C{Check2: 嚴格模式與 Locator 衝突}
    C -- 否: 觸發 Strict Mode Violation --> C1[修正: 定位點加上 role 或 exact]
    C -- 是 --> D{Check3: 等待機制}
    D -- 否: 被偷塞 waitForTimeout --> D1[修正: 移除硬性等待, 等待 API/Auto-waiting]
    D -- 是 --> E{Check4: 斷言品質與隔離性}
    E -- 否: 使用脆弱斷言 --> E1[修正: 改為 Web-First 或精準欄位比對,並確認讀到的筆數]
    E -- 是 --> F[審查通過!納入測試庫]
  1. 架構合規性(Architecture Alignment):

    • AI 是否真的使用了階段 2 生成的 ReviewsPage 與 loggedInPage?有無私下引入原始 test 或手動 page.goto('/login')?
  2. 定位點衝突與 Playwright 嚴格模式 (Strict Mode Violation):

    • 如果 AI 最初寫成 page.getByLabel('Status'),在執行時會因為表頭有 Sort by status ascending 按鈕而觸發 Strict Mode 衝突報錯。審查時應指引 AI 精確定位成 page.getByRole('combobox', { name: 'Status' })。
  3. 等待機制(Auto-waiting 與非同步處理):

    • 程式碼中是否完全乾淨,沒有殘留任何 page.waitForTimeout()?對於帶有 debounce 或網路查詢的操作,是否有妥善包裹 API 等待機制?
  4. 斷言精準度:斷言真的驗到它宣稱的東西了嗎?

    • 這一項最難靠跑測試發現,因為有問題的測試通常還是會通過。這次的 AI 產出的測試就有這個問題。

    TC01 宣稱驗證「所有資料列的 Status 都是 accepted」,實際上只驗了第一列。因為 POM 的 getColumnTexts():

    return (await this.rows.getByRole('cell').nth(index).allInnerTexts())
    

    this.rows.getByRole('cell') 會把所有資料列的 cell 攤平成一整串,.nth(index) 只拿到其中一格,也就是第一列的那個欄位。回傳的陣列長度永遠是 1,every() 只檢查了一筆資料。TC02 的 Product 欄位也一樣。

    修正方式:先把資料列拆成一列一個 Locator,再在每一列裡取第 index 格:

    const rows = await this.rows.all();
    return Promise.all(
      rows.map(async (row) => (await row.getByRole('cell').nth(index).innerText()).trim())
    );
    

    這個 bug 能躲過去,是因為測試從來沒檢查「讀到幾筆」。補上一行之後,就算 POM 以後又寫錯,測試也會直接失敗:

    const statusValues = await reviewsPage.getStatusValues();
    expect(statusValues).toHaveLength(rowCount); // 確認每一列都有讀到
    

    要避免這種測試跑了會過,但實際上測試並沒有寫好的情況,比較可靠的做法是在斷言前加上防線:在驗證整批資料都符合條件之前,先確認真的拿到了整批資料。這樣可以防止少抓到該抓的元素,也避免空陣列直接通過斷言的問題。

    const statusValues = await reviewsPage.getStatusValues();
    expect(statusValues).toHaveLength(rowCount);   // 防線:讀到的筆數要等於列數
    expect(statusValues.every((s) => s === 'accepted')).toBe(true);
    

程式碼驗證提醒

上面範例所使用的程式碼已於 react-admin demo 環境完成實測驗證(指令:npx playwright test day21-reviews-ai 通過),專案中的 ReviewsPage.ts 與測試檔已套用修正。若你在自己的專案中執行,請注意以下幾點:

  1. 確認 POM 路徑與命名:請確保生成的 ReviewsPage.ts 中的 getByRole('combobox', { name: 'Status' }) 與你實際畫面的 Accessibility Tree 相符。
  2. 執行驗證指令:
    npx playwright test day21-reviews-ai --project=chromium --workers=1
    
  3. 檢查 Call Log:若測試發生逾時,請優先使用 Playwright 的 HTML Report 或 Trace Viewer 檢查是卡在 POM 的導航階段還是 Locator 尋找階段,切勿直接調大 timeout。

小結

今天我們透過實作演練,了解如何使用 AI 輔助生成測試的核心心法:

  • 核心心智模型:先確認 Test Case 與結構規範,最後才生成測試程式碼。
  • 分階段生命週期:將流程拆解為「需求發想 (Test Case) ➔ 結構與 POM 生成 (Stage 2) ➔ 測試程式碼生成與審查 (Stage 3)」,避免 AI 一步到位時引發的脈絡混亂與盲猜。

掌握了 AI 生成測試的正確姿勢後,我們的測試撰寫速度將大幅提升。然而,當自動化測試數量越來越多、遇到複雜非同步或平行執行失敗時,我們該如何更有效率地找到問題?

下一篇,我們將進入 Day 22 - Trace Viewer 除錯實戰,學習如何精確還原測試失敗當下的每一毫秒與 DOM 狀態!


上一篇
Day 20 - 檔案上傳與下載:把檔案交給網頁,再把匯出結果帶回來
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言