上一篇讓 AI 生成測試的時候,我們提過一個原則:測試失敗時,先看 Call log 和 Trace Viewer,但錯誤訊息通常只有一兩行,例如「等了 5 秒找不到按鈕」,或「陣列裡不該有 pending」。它告訴我們哪一步失敗,卻不會告訴我們為什麼失敗。
Trace Viewer 的目的就是把測試執行過程中的每個動作、每一刻的畫面、網路請求與 console 訊息都錄下來,讓我們可以回到失敗當下,看看當時的畫面長什麼樣子。
今天我們會用兩個故意寫壞的測試,來練習怎麼讀 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。
| 方式 | 做法 | 適合情境 |
|---|---|---|
| HTML report | npx playwright show-report,點進失敗的測試,再點 Traces 區塊 |
日常最常用,從報告一路點進去 |
| CLI | npx playwright show-trace <trace.zip 路徑> |
手上已經有 zip 檔 |
| 網頁版 | 打開 trace.playwright.dev,把 zip 拖進去 | 同事或 CI 傳來的 trace,本機沒有專案也能看 |
打開 trace 後,畫面大致分成四塊:

| 區塊 | 位置 | 看什麼 |
|---|---|---|
| Timeline | 最上方 | 整支測試的縮圖時間軸,滑過去就能看到每個時間點的畫面 |
| Actions | 左側 | 依序列出每個動作(Click、Fill、Expect…)與花費時間,失敗的動作會標紅 |
| 快照 | 中間 | 選中動作當下的 DOM 快照,分成 Before / Action / After 三個分頁 |
| 詳細資訊 | 下方 | Locator、Call、Log、Errors、Console、Network、Source、Attachments 等分頁 |
其中最常用的是快照的三個分頁:
這些快照不是截圖,而是當下的 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。
終端機的錯誤訊息如下:
TimeoutError: locator.click: Timeout 5000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Add Filters' })
Call log 只說 Playwright 一直在等一個名字是 Add Filters 的按鈕,等了 5 秒還是沒等到。但它沒說這個按鈕是不存在、還沒出現,還是名字不一樣。
打開 trace 後,依序看三個地方:
Click 花了 5.0s,剛好等於我們設定的 timeout。這代表時間全花在「找元素」上,不是點下去之後才出錯。getByRole('button', { name: 'Add filter' })

原來按鈕的 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 查證:
在 Actions 列表點標紅的 Expect "not toContain",看 Action 快照:
...&filter=%7B%7D&...,解碼後是 filter={},篩選條件根本還沒套用到網址上。畫面上的下拉選單已經換了,但列表還是舊的。所以問題不是篩選壞了,而是列表還沒更新。
切到下方的 Network 分頁,在篩選框輸入 reviews?,會看到二筆請求:
| 請求 | 狀態 | Start |
|---|---|---|
reviews?filter={"status":"pending"}... |
200 | 1.0s |
reviews?embed=...&filter={}... |
206 | 1.1s |

(Start 是從 trace 開始錄製起算的時間,你的數字會略有不同,但先後順序應該一致。第一筆是登入後 Dashboard 的待審評論查詢,跟這次篩選無關。)
發現沒有一筆帶著 status: accepted 的查詢,也就是說,斷言執行的時候,篩選請求還沒有被 Browser 網站發出,所以我們應該要等到請求發出後才開始去抓元素。
本來的斷言 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();
});
這段程式有三個重點:
waitForResponse 只會等待被要求等待的請求發出的回應。如果先 click 再開始等,回應有可能在你開始等之前就回來了,測試就會一直等下去。decodeURIComponent() 再比對 "status":"accepted"。如果只比對 /reviews,可能會接到其他的列表查詢,例如篩選前的那一次。toHaveCount(0) 的話,列表剛好是空的時候斷言也會通過,所以要再確認畫面上真的有 accepted 的資料。你可能會想:拿掉 waitForResponse,直接在 click 之後寫最後兩行 web-first assertion,不是也會自動重試嗎?在這個 demo 裡,這樣寫確實也會通過,但它是「剛好」通過的:
expect.timeout(預設 5 秒)。從點下 Accepted 開始,500ms 的 debounce、API 的回應時間、畫面重新渲染,全都要擠在這 5 秒裡。demo 的假 API 很快,所以來得及;但真實專案的 API 一慢,斷言就會在資料回來之前先 timeout。waitForResponse 則不受 expect.timeout 限制,它的上限是整支測試的 timeout。所以兩者是分工,不是二選一:資料用 waitForResponse 等,畫面用 web-first assertion 驗。上一篇 ReviewsPage 裡的 waitForList() 也是同樣的原理:用 waitForResponse 包住會觸發查詢的操作,等資料回來後再確認畫面上的載入狀態結束。
兩個範例走下來,讀 trace 的順序可以整理成:
waitForResponse 等它回來,再用 web-first assertion 驗證畫面。下一篇是緩衝日,我們會請 AI 生成一支測試,再用今天這套流程去驗證跟找出測試失敗的原因。