在自動化測試的學習歷程中,許多人第一次嘗試使用 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 會面臨兩個關鍵的「上下文盲區」:
loggedInPage fixture,也不知道團隊習慣將頁面操作與元素定位整理在 Page Object Model (POM) 中。當上下文不足時,AI 就會盲猜。它會根據訓練資料中的萬用範例,寫出高度耦合、重複發明輪子且不夠可靠的測試腳本。
為了克服這個問題,我們需要將 AI 輔助測試拆解為 3 階段:
[階段 1:需求與 Test Case 發想] ➔ AI 輔助產生 Test Case,明確測試的內容
↓
[階段 2:結構與規範對齊] ➔ 告知/確認專案的架構規範 (POM、Fixture、Locator 慣例)
↓
[階段 3:測試程式碼生成與審查] ➔ 根據定案的 Test Case 與結構,產出高品質 Test Code
撰寫自動化測試的第一步,不是打開 VS Code 寫程式碼,而是釐清要測試什麼。在思考測試情境時,AI 會是很棒的助手,這邊可以由你自己先列出一些 test case 後請 AI 根據專案補強,或是全權交由 AI 處理都可以。
我正在為一個後台管理系統(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 規劃來生成對應的程式碼:
| 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" 欄位。 |
有了明確的 Test Case 之後,接著要告訴 AI 「我們專案的遊戲規則是什麼」。
每支團隊對測試的檔案結構都有不同的架構偏好,我們需要在請 AI 寫測試前,先確定好這個結構,再請 AI 依照這個結構生成測試需要的 locator、page object 跟 fixture。這樣不僅能維持專案規範的一致性,也能 讓 AI 根據指定的架構約束,替我們產生高品質、規範統一的 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 規劃)
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 的基底穩固。
現在我們有了:
ReviewsPage) 與既有的連線 Fixture (loggedInPage)。在這個時間點讓 AI 生成測試腳本,AI 就有了極度明確的上下文,比較不會寫出混亂的程式碼。
我們現在有了定案的 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 與標準斷言來驗證資料列內容與欄位標頭。
// 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);
});
});
即使 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[審查通過!納入測試庫]
架構合規性(Architecture Alignment):
ReviewsPage 與 loggedInPage?有無私下引入原始 test 或手動 page.goto('/login')?定位點衝突與 Playwright 嚴格模式 (Strict Mode Violation):
page.getByLabel('Status'),在執行時會因為表頭有 Sort by status ascending 按鈕而觸發 Strict Mode 衝突報錯。審查時應指引 AI 精確定位成 page.getByRole('combobox', { name: 'Status' })。等待機制(Auto-waiting 與非同步處理):
page.waitForTimeout()?對於帶有 debounce 或網路查詢的操作,是否有妥善包裹 API 等待機制?斷言精準度:斷言真的驗到它宣稱的東西了嗎?
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 與測試檔已套用修正。若你在自己的專案中執行,請注意以下幾點:
ReviewsPage.ts 中的 getByRole('combobox', { name: 'Status' }) 與你實際畫面的 Accessibility Tree 相符。npx playwright test day21-reviews-ai --project=chromium --workers=1
今天我們透過實作演練,了解如何使用 AI 輔助生成測試的核心心法:
掌握了 AI 生成測試的正確姿勢後,我們的測試撰寫速度將大幅提升。然而,當自動化測試數量越來越多、遇到複雜非同步或平行執行失敗時,我們該如何更有效率地找到問題?
下一篇,我們將進入 Day 22 - Trace Viewer 除錯實戰,學習如何精確還原測試失敗當下的每一毫秒與 DOM 狀態!