訂單列表、會員清單、商品搜尋、報表查詢——後台系統一半的畫面都是清單。它們長得不一樣,測法卻高度共通。這篇整理出一套通用套路,學一次,所有列表頁都能用。上一篇的資料驅動,在這裡馬上派上用場。
這篇不走「先講完概念、練習留到最後」的路線。練習直接排進正文——兩個公開示範站,加兩頁讓 Claude Code 自己產的練習頁,每講一個招式,你就跟著在真的網站上跑一次。所以現在就把 Claude Code 打開放旁邊,整篇跟完大約一小時,做完之後這套路已經在你手上跑過一輪,不是「聽過」而已。
開跑前,先知道要看哪四件事
圖 1:清單驗證的四個面向
多數人手動測清單,眼睛其實同時在看四件事,只是沒拆開講:
• 筆數對嗎:搜「耳機」該出現 8 筆,就是 8 筆。
• 內容對嗎:那 8 筆每一筆都真的跟耳機有關,不是混了滑鼠鍵盤進來。
• 排序對嗎:說好新到舊,第一筆就該是最新的。
• 空結果友善嗎:搜一個不存在的東西,畫面顯示「查無資料」的提示,而不是壞掉或一片空白。
這四件事在業界有對應的慣用講法:count(筆數)、content(內容)、order(排序)、empty state(空狀態)。翻各家公司的 E2E 測試專案,列表頁的測試幾乎都繞著這四類斷言在轉,差別只在網站長相。自動化最常見的偷懶,是只驗第一件。筆數對了就綠燈,結果排序壞了三個月沒人發現。後三件才是清單類 bug 最常躲的地方。
概念講完了,接下來全部動手。四站的路線:TodoMVC 練篩選和筆數、自己產的商品頁練搜尋和空結果、SauceDemo 練排序、自己產的訂單頁練分頁,最後帶著整套回你們自己的系統。
第一站:TodoMVC,練篩選與筆數(約 15 分鐘)
篩選是清單的靈魂,先從最乾淨的示範站開始。Playwright 官方的 TodoMVC 沒有登入、沒有廣告,適合暖身。
Step 1|把這段話原封不動貼給 Claude Code
你可以這樣對 Claude Code 說:
到 https://demo.playwright.dev/todomvc 寫一個 Playwright 測試:新增「買牛奶」「回信給客戶」「訂會議室」三筆待辦,勾選第一筆為完成。驗證:點 Active 篩選剩 2 筆、點 Completed 是 1 筆、回到 All 是 3 筆。寫完直接執行給我看結果。
它產出來的核心段落,大概會長這樣:
const todos = ['買牛奶', '回信給客戶', '訂會議室'];
for (const t of todos) {
await page.getByPlaceholder('What needs to be done?').fill(t);
await page.keyboard.press('Enter');
}
await page.getByRole('listitem')
.filter({ hasText: '買牛奶' })
.getByRole('checkbox').check();
await page.getByRole('link', { name: 'Active' }).click();
await expect(page.getByRole('listitem')).toHaveCount(2);
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByRole('listitem')).toHaveCount(1);
await page.getByRole('link', { name: 'All' }).click();
await expect(page.getByRole('listitem')).toHaveCount(3);
測試跑綠之後別急著走,打開它產的 spec 檔,找出 toHaveCount 出現在哪幾行——那就是四面向的第一件「筆數斷言」。它是 web-first assertion,會自動等畫面更新完才判定,所以程式裡不需要、也不應該出現 waitForTimeout(3000) 這種寫死的等待。看到有,直接要求 Claude Code 改掉。
Step 2|把「內容驗證」補進去

圖 2:篩選功能的測試流程
對照圖 2:TodoMVC 的 Active / Completed 就是篩選條件,回到 All 就是清除篩選。剛剛的測試做了①③④,唯獨缺第二步「驗每一筆都符合」——Active 清單裡的 2 筆,有沒有可能混進已完成的那筆?筆數一樣是 2,內容卻是錯的。把這句補給它:
追加一個驗證:切到 Active 之後,清單裡不可以出現「買牛奶」;切到 Completed 之後,清單裡只能有「買牛奶」。
看它加了什麼。業界處理「不可以出現」通常是這兩行的其中一種:
// 寫法一:直接斷言那筆不存在
await expect(page.getByRole('listitem')
.filter({ hasText: '買牛奶' })).toHaveCount(0);
// 寫法二:抓下全部文字,逐筆確認
const texts = await page.getByRole('listitem').allTextContents();
for (const t of texts) {
expect(t).not.toContain('買牛奶');
}
這一步就是真實系統裡「篩了已出貨,結果混進一筆待出貨」那種 bug 的抓法——條件有下、後端沒接好,只驗筆數永遠抓不到。
Step 3|故意弄壞一次
把測試裡 Active 的預期筆數從 2 改成 5,重跑。看終端機吐出的錯誤訊息:Expected: 5, Received: 2。花一分鐘讀懂它,然後改回來。會看失敗訊息,比會寫測試更重要——之後測試紅了,你才分得出是程式壞了,還是預期寫錯。
第二站:自己產一頁商品列表,練搜尋與空結果(約 15 分鐘)
「驗證每一筆結果都包含關鍵字」聽起來要寫迴圈,會嚇到不寫程式的人。放心,那是 AI 的事,你只要把要求說出來。這站不連外面的示範站——與其忍受別人網站的廣告和斷線,不如讓 Claude Code 直接產一頁練習用的商品列表,再對它寫測試。頁面是自己的,還有一個外站給不了的福利,留到 Step 3。
Step 1|先讓 Claude Code 把練習頁生出來
你可以這樣對 Claude Code 說:
幫我產生一個練習用的商品列表頁 products.html,純 HTML+JavaScript、不用後端。內容:10 筆商品,其中 4 筆名稱包含「耳機」,其餘是滑鼠、鍵盤、螢幕等。頁面上方有一個搜尋框(placeholder 寫「搜尋商品」),輸入文字會即時篩選商品名稱;完全沒有符合時,顯示「找不到符合的商品」。每個商品名稱加上 data-testid="product-name" 方便之後測試。
怎麼執行:products.html 是純靜態頁,不用安裝任何東西、也不用啟動伺服器——在檔案總管找到它,雙擊用瀏覽器打開就能玩(或直接跟 Claude Code 說「幫我用瀏覽器打開」)。開起來先手動玩一下:搜「耳機」該剩 4 筆、搜「zzzzz」該出現提示。確認頁面行為正確,再進下一步——先確認受測物是對的,測試才有意義。
Step 2|對自己的頁面寫測試
你可以這樣對 Claude Code 說:
針對 products.html 寫 Playwright 測試:1) 在搜尋框輸入「耳機」,驗證結果至少 1 筆,且每一筆商品名稱都包含「耳機」。2) 輸入「zzzzz」,驗證顯示「找不到符合的商品」,不可以噴錯誤或整頁空白。3) 清空搜尋框,驗證恢復為 10 筆。
測試的執行也一樣簡單:程式用 file:// 直接讀本機檔案,不用架伺服器;在專案資料夾跑 npx playwright test 就會執行,Claude Code 通常會自己跑完、把結果貼給你。它產出的核心段落,業界的標準寫法大概是:
test('搜尋「耳機」,每一筆都相關', async ({ page }) => {
await page.goto('file:///' + __dirname + '/products.html');
await page.getByPlaceholder('搜尋商品').fill('耳機');
const items = page.getByTestId('product-name');
// 1) 至少 1 筆
await expect.poll(() => items.count()).toBeGreaterThan(0);
// 2) 每一筆都包含關鍵字——「很可怕的迴圈」就這三行
const names = await items.allTextContents();
for (const name of names) {
expect(name).toContain('耳機');
}
// 3) 空結果要友善
await page.getByPlaceholder('搜尋商品').fill('zzzzz');
await expect(page.getByText('找不到符合的商品')).toBeVisible();
// 4) 清空,清單恢復
await page.getByPlaceholder('搜尋商品').fill('');
await expect(items).toHaveCount(10);
});
三個驗收重點。
第一,allTextContents() 一次抓下所有名稱再逐筆檢查,10 筆或 200 筆都是同一段程式。
第二,第 3 點「搜不到」是你的貢獻:無結果畫面是使用者天天遇到、測試最常忘記的場景,壞起來特別難看。
第三,第 4 點就是篩選流程的第四步「清除、驗恢復」——真實系統出過「清除篩選後清單回不去,要重新整理才正常」的 bug,先在這裡把埋伏練起來。
Step 3|埋一隻 bug,驗證測試真的抓得到
這就是自己產頁面的福利:受測物在你手上,可以故意弄壞它。跟 Claude Code 說:
把 products.html 的搜尋邏輯故意改壞:搜「耳機」時,多混進一筆「電競滑鼠」。改完重跑剛剛的測試。
測試應該要紅,錯誤訊息會指出「電競滑鼠」不包含「耳機」。如果測試還是綠的,代表它沒有真的逐筆驗內容——這種測試上了 CI 也是裝飾品。確認會紅之後,請它把 bug 改回來。這招業界叫做給測試「對照組」:連壞的都抓不到的測試,綠燈不代表任何事。真實系統裡「篩了已出貨,混進一筆待出貨」就是同一型的 bug,你剛剛已經演練過怎麼抓它了。
第三站:SauceDemo,練排序(約 15 分鐘)
排序的驗證,難的不是程式,是把「對」定義清楚。「價格由小到大」對程式來說是:第一筆的價格,要小於或等於第二筆,以此類推。你不用會寫比較邏輯,把規則講白就好。
Step 1|把排序需求講白,貼給 Claude Code
你可以這樣對 Claude Code 說:
到 https://www.saucedemo.com 用帳號 standard_user、密碼 secret_sauce 登入。把商品排序切成 Price (low to high),抓出每個商品的價格,驗證由上到下是遞增的。再切成 Name (Z to A),驗證商品名稱是反向字母序。
業界驗排序有個通用招式:把畫面上的值抓下來,自己排一份「正確答案」,比對兩者是否一致。

圖 3:排序驗證的通用招式
await page.locator('.product_sort_container')
.selectOption('lohi'); // Price (low to high)
const priceTexts = await page
.locator('.inventory_item_price').allTextContents();
const prices = priceTexts.map((t) => Number(t.replace('$', '')));
// 自己排一份遞增的「正確答案」,跟畫面上的順序比對
const expected = [...prices].sort((a, b) => a - b);
expect(prices).toEqual(expected);
// 名稱 Z 到 A:同一招,換成字串反向排序
await page.locator('.product_sort_container').selectOption('za');
const names = await page
.locator('.inventory_item_name').allTextContents();
expect(names).toEqual([...names].sort().reverse());
Step 2|對答案:那兩行在不在
打開 Claude Code 產的程式,找「複製一份、自己排序,再 toEqual 比對」的那兩行。有,代表它用的正是業界標準寫法;沒有(例如它只比了第一筆和最後一筆),把上面這段貼給它,請它照這個模式改。另外注意價格那行有在拆掉 $ 符號——畫面顯示的是字串,要先轉成數字才能比大小,這是 AI 偶爾會漏的細節,跑出詭異結果時先往這裡查。
順帶一個實戰提醒:排序測試對測試資料很敏感。SauceDemo 的商品價格剛好各不相同,所以這個測試有鑑別力;但回到你們系統,如果所有測試訂單都是同一秒建立的,新到舊怎麼排都對,測試等於沒測。資料要刻意鋪出差異,這就接回上一篇資料驅動的功夫,也是第 18 篇用 API 備料的預告。
第四站:自己產一頁訂單列表,練分頁(約 15 分鐘)
公開示範站幾乎都沒有真的分頁,所以這站繼續用第二站的招:讓 Claude Code 產一頁有分頁的訂單列表,三個檢查點全部在自己機器上跑完。

圖 4:分頁的三個檢查點
Step 1|產一頁有分頁的訂單列表
你可以這樣對 Claude Code 說:
幫我產生一個練習用的訂單列表頁 orders.html,純 HTML+JavaScript、不用後端。內容:25 筆訂單,欄位有訂單編號(ORD-001 到 ORD-025)、金額(互不相同)、狀態(已出貨與待出貨交錯)。每頁顯示 10 筆,有「上一頁」「下一頁」按鈕,並顯示目前頁碼(例如「第 1 頁,共 3 頁」)。上方有一個訂單狀態的下拉篩選;在任何頁面下篩選,都要回到第 1 頁重新計算。訂單編號加上 data-testid="order-id"、狀態加上 data-testid="order-status"。
怎麼執行:跟 products.html 一樣,雙擊用瀏覽器打開,不用架任何東西。先手動確認三件事:共 3 頁、第 3 頁只有 5 筆(25 筆的餘數)、翻到第 2 頁下「已出貨」篩選會跳回第 1 頁。行為都對了再往下。
Step 2|把三個檢查點交給測試
你可以這樣對 Claude Code 說:
針對 orders.html 寫 Playwright 測試:1) 抓第 1 頁的所有訂單編號,翻到第 2 頁再抓一次,驗證兩頁完全不重複。2) 翻到第 3 頁,驗證只有 5 筆。3) 翻到第 2 頁之後選「已出貨」篩選,驗證回到第 1 頁,而且每一筆的狀態都是已出貨。
「兩頁內容不重複」業界的慣用寫法,是把兩頁的識別欄位各抓一份,用集合比交集。三個檢查點的程式大概長這樣:
test('第二頁內容不與第一頁重複', async ({ page }) => {
await page.goto('file:///' + __dirname + '/orders.html');
const page1 = await page.getByTestId('order-id').allTextContents();
await page.getByRole('button', { name: '下一頁' }).click();
const page2 = await page.getByTestId('order-id').allTextContents();
const overlap = page2.filter((id) => page1.includes(id));
expect(overlap).toEqual([]); // 交集應該是空的
});
test('最後一頁是正確的餘數', async ({ page }) => {
await page.goto('file:///' + __dirname + '/orders.html');
await page.getByRole('button', { name: '下一頁' }).click();
await page.getByRole('button', { name: '下一頁' }).click();
await expect(page.getByTestId('order-id')).toHaveCount(5);
});
test('第二頁下篩選,回到第一頁重新計算', async ({ page }) => {
await page.goto('file:///' + __dirname + '/orders.html');
await page.getByRole('button', { name: '下一頁' }).click();
await page.getByLabel('訂單狀態').selectOption('已出貨');
await expect(page.getByText('第 1 頁')).toBeVisible();
const statuses = await page.getByTestId('order-status').allTextContents();
for (const s of statuses) {
expect(s).toBe('已出貨');
}
});
第三個檢查點在真實系統是那種「壞了很難看、測試很少寫」的地方——使用者停在一個不存在的頁碼,畫面一片空白。想加碼的話,套用第二站的埋 bug 招:請 Claude Code 把「下篩選回第一頁」的行為故意弄壞,看第三個測試會不會紅。
最後一站:回到你們自己的系統
四站跑完,套路已經在手上。現在挑一個真實列表頁——訂單、會員、商品都行——把同一套搬過去:
用四面向(筆數、內容、排序、空結果)盤點這頁的手動檢查點,寫成一段中文描述交給 Claude Code 轉成測試。格式照著前面三站的提示寫就行。
刻意驗一次「無結果」和「清除篩選」——不少人在這步就直接抓到 bug 了。
如果這頁有多個篩選條件疊加(狀態+日期區間+關鍵字),不用每種組合都測。回想第 14 篇組合爆炸的提醒:每個條件單獨測一次,再挑一兩個實務上最常用的組合,就有八成的保護力。
驗收 AI 產出時盯兩件事:有沒有 waitForTimeout 這種寫死的等待(有就請它改用 web-first assertion);排序驗證有沒有真的逐筆比對,而不是只看第一筆。
這篇的四段程式你一行都沒寫,但每一段你都看得懂在驗什麼、也知道壞掉時去哪裡查。這就夠了——把預期講清楚是你的事,迴圈和斷言是 AI 的事,分工從頭到尾沒變過。