iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
自我挑戰組

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

Day 23 - 緩衝日③:AI 生成測試的 debug 實戰

  • 分享至 

  • xImage
  •  

前兩篇分別讓 AI 生成測試,然後說明測試失敗時怎麼用 trace 找出失敗原因。今天是第三個緩衝日,讓我們練習實際用AI生成測試然後用 trace 找出失敗原因吧!

實際上在產出測試時,因為AI產出具有隨機性,如果生出的測試程式跟這篇的內容有不一樣是很正常的,所以這篇的重點會是在利用AI生成測試跟練習使用trace找出失敗原因這件事,只要知道你要怎麼針對你的AI產出的程式去做驗證跟修正就好。

情境題目

這次挑的是 react-admin demo 原本就有的商品編輯頁(/#/products/1):

  1. 切到 Details 分頁,把 Price 改成 99.9
  2. 切到 Description 分頁,把描述改成 Updated by Day23
  3. 按 Save,確認資料真的存到後端

流程只有三步,但它在三個不同的層次都可能出錯:

可能出錯的地方 原因
分頁 TabbedForm 沒切到分頁,DOM 結構中有這個欄位,只是是在別的分頁中
富文本編輯器 Description 是 contenteditable 的編輯器,不是一般的 <input>,而且 label 是空字串
儲存時機 react-admin 的 Edit 預設是 undoable 模式:按下 Save 後會先顯示通知並提供 Undo,請求要晚一點才真正送出

對 AI 來說,這三件事都沒辦法只靠「看需求」就猜對,所以很適合拿來練習。

實作記錄

1. 讓 AI 產生測試

直接讓 AI去讀取整個codebase,了解react-admin的運作模式,寫出一支完整的測試程式,以下是這次的prompt:

請用 Playwright + TypeScript 寫一支測試:
登入後進入 /#/products/1,在 Details 分頁把 Price 改成 99.9,
在 Description 分頁把描述改成「Updated by Day23」,按 Save 並確認資料真的存到後端。
規範:
- 從 '../fixtures/fixtures' 匯入 test / expect,使用 loggedInPage fixture
- 每個階段用 test.step 包起來

這次特別要求AI產出要每個階段用 test.step 包起來,這邊補充說明一下。

test.step 是什麼?

test.step 主要是用來將一個測試程式碼結構依步驟拆分,每個步驟都可以有自己的小標題讓人識別這個步驟在做什麼,也讓你看 HTML 報告和追蹤記錄能更容易理解,測試失敗時可以很明確知道是在哪個步驟出問題。

await test.step('Details 分頁:修改價格', async () => {
    await page.getByRole('tab', { name: 'Details' }).click();
    await page.getByLabel('Price').fill('99.9');
});

AI 產出的程式碼

// playwright-tests/tests/day23-gemini-ai.spec.ts
import { test, expect } from '../fixtures/fixtures';

test('編輯產品 (ID: 1) 之價格與描述並確認儲存至後端', async ({ loggedInPage: page }) => {
    // 1. 前往產品編輯頁面
    await test.step('前往產品 1 編輯頁面', async () => {
        await page.goto('/#/products/1');
        await page.waitForLoadState('networkidle');
    });

    // 2. 切換至 Details 分頁並修改 Price
    await test.step('在 Details 分頁修改 Price 為 99.9', async () => {
        await page.getByRole('tab', { name: 'Details' }).click();
        const priceInput = page.getByLabel('Price');
        await priceInput.fill('99.9');
        await expect(priceInput).toHaveValue('99.9');
    });

    // 3. 切換至 Description 分頁並修改描述
    await test.step('在 Description 分頁修改描述為「Updated by Day23」', async () => {
        await page.getByRole('tab', { name: 'Description' }).click();
        const descriptionEditor = page.locator('.ProseMirror');
        await descriptionEditor.fill('Updated by Day23');
        await expect(descriptionEditor).toContainText('Updated by Day23');
    });

    // 4. 按下 Save 並驗證後端 API 回應與 UI 提示
    await test.step('點擊 Save 並確認資料成功儲存至後端', async () => {
        // 設定監聽器:等待產品更新 (PUT / PATCH) 的後端 API 回應
        const saveResponsePromise = page.waitForResponse(
            (response) =>
                response.url().includes('/products/1') &&
                ['PUT', 'PATCH'].includes(response.request().method())
        );

        // 點擊 Save 按鈕
        await page.getByRole('button', { name: 'Save' }).click();

        // 驗證後端 API 回應狀態碼與 Payload
        const response = await saveResponsePromise;
        expect(response.status()).toBe(200);

        const responseData = await response.json();
        expect(responseData.price).toBe(99.9);
        expect(responseData.description).toContain('Updated by Day23');

        // 驗證畫面出現成功儲存訊息提示 (Toast)
        await expect(page.getByText(/Element updated|Poster updated/i)).toBeVisible();
    });
});

執行之前,我們先快速看一下他產出的內容:

檢查項目 觀察
架構合規性 有用 loggedInPage fixture,沒有自己重寫登入 ✅
Locator 分頁、欄位、按鈕都用 role 或 label 定位;富文本編輯器用 .ProseMirror 這個 class 定位,可以確認一下是否有更好的定位做法 ⚠️
等待機制 沒有 waitForTimeout ✅;但用了 waitForLoadState('networkidle'),這是官方不建議的等待方式 ⚠️
斷言品質 填完欄位後有馬上確認值;儲存後不只看狀態碼,還檢查了回應裡的 price 和 description ✅

整體來說,這支測試寫得比預期好:它知道要先切分頁、知道編輯器不是一般的 input,儲存後也確實去驗證後端收到的資料,而不是只看畫面。不過最後一行:/Element updated|Poster updated/i看起來 AI 不確定通知會顯示哪段文字,所以寫了一個兩種都接受的正規表示式,這部分可能要人工確認一下。

接下來我們會跑測試,如果有錯誤就試看看使用 trace viewer 來協助除錯。另外元素定位部分的調整,因為之前的篇章已經介紹過了,這篇就不再重述,大家可以自己試試看。

2. 執行:失敗了

因為我們沒有要求AI要開啟trace viewer功能,所以我們直接在執行時補上--trace on,可以看到以下的錯誤訊息:

npx playwright test day23-gemini-ai --project=chromium --trace on
Error: expect(locator).toBeVisible() failed

Locator: getByText(/Element updated|Poster updated/i)
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeVisible" with timeout 5000ms
  - waiting for getByText(/Element updated|Poster updated/i)

3. 用 trace 找根因

第一步:確認是哪個階段壞的。

因為有 test.step,動作列表會照四個步驟分組。前三個步驟都通過了,失敗的是最後一個「點擊 Save 並確認資料成功儲存至後端」裡的 toBeVisible。初步排除其他測試步驟有問題的可能。

https://ithelp.ithome.com.tw/upload/images/20261004/20184177HUmQmz8QVv.png

第二步:看失敗那一步的快照。

錯誤訊息是 element(s) not found。第一個直覺通常是「文字寫錯了」,而且 AI 自己也不確定通知文字,寫了 /Element updated|Poster updated/i,看起來更像是文字的問題。

我們先點開失敗的那一步,看它的快照發現畫面已經回到商品列表頁,且沒看到任何寫著Element updated或是Poster updated的通知。

https://ithelp.ithome.com.tw/upload/images/20261004/20184177fQ39Bx6q7o.png

通知不在,可能是它根本沒出現過,或者出現過但已經消失了。只看這一張快照還分不出來,我們再往前找。

第三步:往前找,通知到底有沒有出現過?

往上點選 Click Save 那一步,看它的 After 快照,發現通知就在畫面下方:

Poster updated    UNDO

https://ithelp.ithome.com.tw/upload/images/20261004/201841779TAF2mq7lO.png

這表示:

  1. 通知文字是 Poster updated,AI 寫的正規表示式其中一個有對應到,所以應該不是文字的問題。
  2. 通知在按下 Save 後就立刻出現了,只是等到斷言執行時,它已經不見了。

問題從「找不到文字」變成為什麼斷言執行的時候,通知已經消失了?

第四步:用 Network 對時間。

打開 Network 分頁,找 PUT https://demo.api.marmelab.com/products/1,發現這個請求是在8.8秒才發送出去。

再點選左側動作後查看下方Call顯示該動作的起始時間:

時間(測試開始後) 發生的事
約 4.4 秒 按下 Save,通知出現
約 9.1 秒 開始執行 toBeVisible,通知早已消失,等了 5 秒後失敗

https://ithelp.ithome.com.tw/upload/images/20261004/20184177IygCC73atx.png

這樣我們可以推論,按下 Save 後可能是先顯示通知,讓使用者有幾秒鐘可以按 Undo,之後通知關閉,PUT 請求才真正送出,等到斷言執行的時候,通知早就消失了。

回頭看 AI 的程式碼順序:

await page.getByRole('button', { name: 'Save' }).click();

const response = await saveResponsePromise;   // ← 在這裡等了 4 秒多,等通知關閉、PUT 送出
// ...檢查 response...

await expect(page.getByText(/Element updated|Poster updated/i)).toBeVisible();  // ← 通知早就消失了

AI 先等 PUT 回來,最後才檢查通知。但實際上的順序應該是先出現通知,然後等待通知消失,PUT 請求才會被送出,所以目前測試的這個寫法註定會失敗。

另外,Network 也顯示 PUT 確實有送出、回應 200,response 裡的 price 和 description 也都通過了斷言,所以 waitForResponse 這段是正確的。

4. 修正

既然知道問題是在斷言的順序,那我們調換順序試看看,順便把正規表示式改成'Poster updated',然後重跑一次,看新的 trace。

         await page.getByRole('button', { name: 'Save' }).click();

+        // 通知在按下 Save 後立刻出現,要在它消失前檢查
+        await expect(page.getByText('Poster updated')).toBeVisible();
+
+        // 通知關閉後才會送出 PUT
         const response = await saveResponsePromise;
         expect(response.status()).toBe(200);

         const responseData = await response.json();
         expect(responseData.price).toBe(99.9);
         expect(responseData.description).toContain('Updated by Day23');
-
-        // 驗證畫面出現成功儲存訊息提示 (Toast)
-        await expect(page.getByText(/Element updated|Poster updated/i)).toBeVisible();

重跑之後,測試全部通過!

修正時要不要交給 AI?

可以,但最好可以提供一些你目前排查的狀況,而不是只說「測試壞了」。比較兩種說法:

  • ❌「這個測試失敗了,幫我修。」
  • ✅「最後一行找不到通知。trace 顯示按下 Save 後通知 Poster updated 立刻出現;約 4 秒後通知關閉,PUT 才送出;而斷言是在 PUT 回來之後才執行,那時通知已經消失。」

第一種說法,AI 看到 element(s) not found,很可能會以為是文字寫錯,再換一組文字押寶,或者把 timeout 調大。第二種說法已經把時間順序講清楚了,AI 要做的只是調換程式碼的順序。

AI 給的修正依然只是假設,要再跑一次、看新的 trace,確認它是對的。

踩雷/意外發現

錯誤訊息會把你帶往錯的方向

原本的錯誤訊息是 element(s) not found 加上 AI 本來寫的正規表示式,可能會很自然地以為是文字寫錯才找不到元素,但實際看trace發現文字其實是對的。

而真正的原因是斷言的時機不對,如果沒有研究每個動作及請求的開始時間,會很難發現問題是在驗證時間點的問題。

小結

今天完整走了一次 AI 產生測試 → 執行失敗 → trace 找證據 → 修正 → 再跑 的過程。請 AI 生成測試時要求用 test.step 分段,失敗時可以直接看出是哪個階段壞的。針對錯誤訊息有時候還是需要透過trace提供的詳細資訊判斷測試失敗的原因是什麼,才能正確修正測試。

下一篇開始進入資料與登入優化的階段,第一個主題是參數化測試。


上一篇
Day 22 - Trace Viewer 除錯實戰:如何透過Trace Viewer協助除錯
下一篇
Day 24 - 參數化測試:一份資料表,產生多支測試
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言