iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
自我挑戰組

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

Day 22 - Trace Viewer 除錯實戰:如何透過Trace Viewer協助除錯

  • 分享至 

  • xImage
  •  

上一篇讓 AI 生成測試的時候,我們提過一個原則:測試失敗時,先看 Call log 和 Trace Viewer,但錯誤訊息通常只有一兩行,例如「等了 5 秒找不到按鈕」,或「陣列裡不該有 pending」。它告訴我們哪一步失敗,卻不會告訴我們為什麼失敗。

Trace Viewer 的目的就是把測試執行過程中的每個動作、每一刻的畫面、網路請求與 console 訊息都錄下來,讓我們可以回到失敗當下,看看當時的畫面長什麼樣子。

今天我們會用兩個故意寫壞的測試,來練習怎麼讀 trace:

  1. Locator 寫錯:錯誤訊息很清楚,但要到畫面上才找得到正確答案。
  2. 斷言讀到舊資料:錯誤訊息看起來完全合理,要靠 trace 的時間軸才找得到根因。

先讓測試留下 trace

在介紹除錯產出物的那篇我們提過目前的設定是trace: 'on-first-retry',配上本機的 retries: 0,本機失敗時永遠不會產生 trace。

今天我們直接在測試檔案開頭用 test.use() 覆寫設定,讓這支檔案的每個測試都錄 trace:

test.use({ trace: 'on' });

如果不想改程式碼,也可以在執行時用 CLI 開啟:

npx playwright test --trace on

執行後,每個測試在 test-results/ 底下的資料夾都會多一個 trace.zip。

打開 trace 的三種方式

方式 做法 適合情境
HTML report npx playwright show-report,點進失敗的測試,再點 Traces 區塊 日常最常用,從報告一路點進去
CLI npx playwright show-trace <trace.zip 路徑> 手上已經有 zip 檔
網頁版 打開 trace.playwright.dev,把 zip 拖進去 同事或 CI 傳來的 trace,本機沒有專案也能看

Trace Viewer 的介面

打開 trace 後,畫面大致分成四塊:

https://ithelp.ithome.com.tw/upload/images/20261003/20184177dmZQoKtVn2.png

區塊 位置 看什麼
Timeline 最上方 整支測試的縮圖時間軸,滑過去就能看到每個時間點的畫面
Actions 左側 依序列出每個動作(Click、Fill、Expect…)與花費時間,失敗的動作會標紅
快照 中間 選中動作當下的 DOM 快照,分成 Before / Action / After 三個分頁
詳細資訊 下方 Locator、Call、Log、Errors、Console、Network、Source、Attachments 等分頁

其中最常用的是快照的三個分頁:

  • Before:動作執行之前的畫面。定位失敗時,先看這裡確認元素到底在不在。
  • Action:動作執行當下的畫面,會標出 Playwright 實際點擊的位置。
  • After:動作執行之後的畫面,用來確認這個動作有沒有造成預期的變化。

這些快照不是截圖,而是當下的 DOM。所以可以在上面 inspect 元素,也可以用接下來會用到的 Pick locator 直接挑出 locator。

練習用的測試檔

新增 playwright-tests/tests/day22-trace-viewer.spec.ts。沿用上一篇的 ReviewsPage 與 loggedInPage fixture:

// playwright-tests/tests/day22-trace-viewer.spec.ts
import { test, expect } from '../fixtures/fixtures';
import { ReviewsPage } from '../pages/ReviewsPage';

// 本檔強制錄 trace,不受 config 的 on-first-retry 影響
test.use({ trace: 'on' });

test.describe('Day22 - Trace Viewer 除錯實戰', () => {
  // 範例一:locator 名稱寫錯(畫面上的按鈕其實是 "Add filter")
  test('範例一:篩選按鈕名稱寫錯導致 timeout', async ({ loggedInPage }) => {
    const reviewsPage = new ReviewsPage(loggedInPage);
    await reviewsPage.goto();

    await loggedInPage
      .getByRole('button', { name: 'Add Filters' })
      .click({ timeout: 5000 });
  });

  // 範例二:選完篩選條件後立刻讀表格,沒等列表重新查詢
  test('範例二:沒等列表更新就讀取資料', async ({ loggedInPage }) => {
    const page = loggedInPage;
    const reviewsPage = new ReviewsPage(page);
    await reviewsPage.goto();

    await page.getByRole('button', { name: 'Add filter' }).click();
    await page
      .getByRole('menuitemcheckbox', { name: 'Status' })
      // .or(page.getByRole('menuitem', { name: 'Status' }))
      .click();
    await page.getByRole('combobox', { name: 'Status' }).click();
    await page.getByRole('option', { name: 'Accepted', exact: true }).click();

    // ❌ 讀取「當下」的畫面後才比對,不會重試
    const statusValues = await reviewsPage.getStatusValues();
    expect(statusValues).not.toContain('pending');
  });
});

執行:

npx playwright test day22-trace-viewer --project=chromium --workers=1

兩支測試都會失敗,這是預期的結果。接下來逐一打開 trace。

範例一:Locator 寫錯

終端機的錯誤訊息如下:

TimeoutError: locator.click: Timeout 5000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Add Filters' })

Call log 只說 Playwright 一直在等一個名字是 Add Filters 的按鈕,等了 5 秒還是沒等到。但它沒說這個按鈕是不存在、還沒出現,還是名字不一樣。

打開 trace 後,依序看三個地方:

  1. Actions 列表:標紅的 Click 花了 5.0s,剛好等於我們設定的 timeout。這代表時間全花在「找元素」上,不是點下去之後才出錯。
  2. Before 快照:畫面右上角清楚地有一顆 ADD FILTER 按鈕,而且列表已經載入完成。元素存在,也不是時機問題,那就只剩下名字對不上。
  3. Pick locator:點快照上方的準心圖示,再點畫面上的 ADD FILTER 按鈕。下方 Locator 分頁會直接產生:
getByRole('button', { name: 'Add filter' })

範例一的 Before 快照 + Locator 分頁顯示 Pick locator 的結果

原來按鈕的 accessible name 是 Add filter,畫面上看到的全大寫 ADD FILTER 只是 CSS 樣式。getByRole 的 name 預設是不分大小寫的子字串比對,所以 Add filter、add filter 都找得到它;但 Add Filters 多了一個 s,就找不到了。

範例二:斷言讀到舊資料

範例二是今天的重點。錯誤訊息如下:

Error: expect(received).not.toContain(expected)

Expected value: not "pending"
Received array: ["rejected", "accepted", "accepted", "accepted", "pending", ...]

光看錯誤訊息,很容易得出錯誤的結論:「篩選功能壞了,選了 Accepted 還是出現 pending」。

打開 trace 查證:

1. 看失敗那一步的快照

在 Actions 列表點標紅的 Expect "not toContain",看 Action 快照:

  • 篩選欄位的 Status 下拉已經顯示 Accepted。
  • 表格裡還是混著 pending 和 rejected。
  • 再看快照上方的網址:...&filter=%7B%7D&...,解碼後是 filter={},篩選條件根本還沒套用到網址上。

畫面上的下拉選單已經換了,但列表還是舊的。所以問題不是篩選壞了,而是列表還沒更新。

2. 用 Network 分頁對時間

切到下方的 Network 分頁,在篩選框輸入 reviews?,會看到二筆請求:

請求 狀態 Start
reviews?filter={"status":"pending"}... 200 1.0s
reviews?embed=...&filter={}... 206 1.1s

https://ithelp.ithome.com.tw/upload/images/20261003/20184177GidJw5IKZH.png

(Start 是從 trace 開始錄製起算的時間,你的數字會略有不同,但先後順序應該一致。第一筆是登入後 Dashboard 的待審評論查詢,跟這次篩選無關。)

發現沒有一筆帶著 status: accepted 的查詢,也就是說,斷言執行的時候,篩選請求還沒有被 Browser 網站發出,所以我們應該要等到請求發出後才開始去抓元素。

3. 修正:先等資料回來,再驗證畫面

本來的斷言 getStatusValues() 只讀一次當下的畫面,拿到陣列後才交給 expect() 比對,所以不會重試。但問題不只是「沒有重試」。Trace 已經告訴我們,斷言執行時,帶 accepted 條件的查詢根本還沒送出。所以修正的重點是:先等這支請求回來,再去驗證畫面。

  // 範例二修正版:先等篩選後的 API 回應,再用 web-first assertion 驗證畫面
  test('範例二修正版:等待 API 回應後再驗證畫面', async ({ loggedInPage }) => {
    const page = loggedInPage;
    const reviewsPage = new ReviewsPage(page);
    await reviewsPage.goto();

    await page.getByRole('button', { name: 'Add filter' }).click();
    await page
      .getByRole('menuitemcheckbox', { name: 'Status' })
      .or(page.getByRole('menuitem', { name: 'Status' }))
      .click();
    await page.getByRole('combobox', { name: 'Status' }).click();

    // 1. 點擊「之前」先開始監聽帶 accepted 條件的列表查詢
    const acceptedResponse = page.waitForResponse(
      (res) =>
        res.request().method() === 'GET' &&
        res.url().includes('/reviews?') &&
        decodeURIComponent(res.url()).includes('"status":"accepted"')
    );
    await page.getByRole('option', { name: 'Accepted', exact: true }).click();

    // 2. 等資料真的回來,並確認請求成功
    const response = await acceptedResponse;
    expect(response.ok()).toBe(true);

    // 3. 資料回來 ≠ 畫面已重新渲染,畫面驗證仍用 web-first assertion
    const table = page.getByRole('table');
    await expect(table.getByRole('cell', { name: 'pending', exact: true })).toHaveCount(0);
    // 防線:確認列表不是空的,而且真的出現 accepted
    await expect(table.getByRole('cell', { name: 'accepted', exact: true }).first()).toBeVisible();
  });

這段程式有三個重點:

  • 監聽要在 click 之前建立。waitForResponse 只會等待被要求等待的請求發出的回應。如果先 click 再開始等,回應有可能在你開始等之前就回來了,測試就會一直等下去。
  • URL 條件要比對到篩選條件本身。網址裡的篩選參數是編碼過的,所以先 decodeURIComponent() 再比對 "status":"accepted"。如果只比對 /reviews,可能會接到其他的列表查詢,例如篩選前的那一次。
  • API 回來之後,畫面仍用 web-first assertion 驗證。資料回來後,React 還要重新渲染列表,這段時間很短,但不是零。所以最後兩行仍然要能自動重試。第二個斷言則是上一篇提過的「防線」:只寫 toHaveCount(0) 的話,列表剛好是空的時候斷言也會通過,所以要再確認畫面上真的有 accepted 的資料。

為什麼不只用 web-first assertion?

你可能會想:拿掉 waitForResponse,直接在 click 之後寫最後兩行 web-first assertion,不是也會自動重試嗎?在這個 demo 裡,這樣寫確實也會通過,但它是「剛好」通過的:

  • 等待預算不同。web-first assertion 最多只重試到 expect.timeout(預設 5 秒)。從點下 Accepted 開始,500ms 的 debounce、API 的回應時間、畫面重新渲染,全都要擠在這 5 秒裡。demo 的假 API 很快,所以來得及;但真實專案的 API 一慢,斷言就會在資料回來之前先 timeout。waitForResponse 則不受 expect.timeout 限制,它的上限是整支測試的 timeout。
  • 測試沒有表達真正的依賴。只看畫面的斷言,分不出到底是新資料到了和還是舊畫面剛好符合條件。如果篩選前的列表碰巧沒有 pending,斷言會直接通過,但其實什麼都沒驗到。明確等待那支請求,才是把「畫面必須來自篩選後的資料」這件事寫進測試。

所以兩者是分工,不是二選一:資料用 waitForResponse 等,畫面用 web-first assertion 驗。上一篇 ReviewsPage 裡的 waitForList() 也是同樣的原理:用 waitForResponse 包住會觸發查詢的操作,等資料回來後再確認畫面上的載入狀態結束。

除錯流程整理

兩個範例走下來,讀 trace 的順序可以整理成:

  1. Errors / Call log:先確認是哪一步、等什麼條件失敗。
  2. 失敗那一步的快照:Before 看元素在不在;Action / After 看畫面跟你預期的一不一樣。
  3. Network / Console:畫面「還沒變」的時候,資料查詢有沒有送出、什麼時候回來、有沒有報錯。
  4. 往前找第一個不對勁的動作:失敗的那一步有時候不是真正問題發生的地方,真正的原因通常更早就發生了。
  5. 最後才考慮調 timeout:如果 trace 顯示確實只是慢,而且慢得合理,再調整。

小結

  • Trace Viewer 把每個動作的 DOM 快照、網路請求、console 串在同一條時間軸上,讓我們能回到失敗當下,而不是只能看錯誤訊息自行反推跟測試可能出問題的地方。
  • Before 快照 + Pick locator 適合處理「找不到元素」:確認元素在不在,再直接挑出正確的 locator。
  • Network 分頁對時間適合處理「畫面沒更新」:確認斷言和資料請求的先後順序。找到那支請求後,修正時就用 waitForResponse 等它回來,再用 web-first assertion 驗證畫面。

下一篇是緩衝日,我們會請 AI 生成一支測試,再用今天這套流程去驗證跟找出測試失敗的原因。


上一篇
Day 21 - AI 輔助生成測試:如何與AI協作產生高品質測試
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言