在講 Worker 的那篇,我們為了觀察測試怎麼被分配到各個 worker,用一個 for 迴圈一次產生了「觀察 1」到「觀察 6」六支測試。當時沒有特別解釋為什麼可以這樣寫,所以今天就來正式介紹這個寫法:參數化測試。
先想像一個情境:商品列表左側有一排分類篩選(Animals、Beard、Cars……),我們想確認每個分類點下去,列表都會顯示正確的商品。流程完全一樣,差別只在「點哪個分類」、「預期看到什麼」。如果每個分類都複製貼上一支測試,五個分類就是五段幾乎一樣的程式碼,之後要改其中一行,就得改五次。
參數化測試就是要讓相同的測試流程只要寫一次,由資料決定要跑幾支測試跟各個測試的參數要用哪些,避免測試程式碼重複。
第一次想到「用迴圈跑多筆資料」,直覺可能會寫成這樣:
// playwright-tests/tests/day24-parameterize.spec.ts
import { test, expect } from '../fixtures/fixtures';
test('反例:迴圈寫在 test 裡', async ({ loggedInPage: page }) => {
const cases = [
{ category: 'Animals', sample: 'Cat Nose' },
{ category: 'Beard', sample: '故意寫錯的名字' }, // ← 故意失敗
{ category: 'Cars', sample: 'Old Combi' },
];
await page.getByRole('menuitem', { name: 'Posters' }).click();
for (const { category, sample } of cases) {
await page.getByRole('button', { name: category, exact: true }).click();
await expect(page.getByRole('link', { name: new RegExp(`^${sample}`) })).toBeVisible();
await page.getByRole('button', { name: category, exact: true }).click(); // 取消篩選
}
});
這裡故意把第二筆的預期品名寫錯,執行後會得到:
Error: expect(locator).toBeVisible() failed
Locator: getByRole('link', { name: /^故意寫錯的名字/ })
Expected: visible
Timeout: 5000ms
Error: element(s) not found
看起來沒什麼問題,錯誤也確實抓到了,但仔細看會發現:
把迴圈從 test() 裡面搬到外面:
// 每筆資料 = 輸入(category)+ 預期值(sample)
const categories = [
{ category: 'Animals', sample: 'Cat Nose' },
{ category: 'Beard', sample: 'Black Auburn' },
{ category: 'Cars', sample: 'Old Combi' },
{ category: 'Food', sample: 'Fuzzy Forks' },
{ category: 'Tech', sample: 'Black Screen' },
];
test.describe('商品分類篩選', () => {
for (const { category, sample } of categories) {
test(`分類 ${category} 顯示 10 筆且包含 ${sample}`, async ({ loggedInPage: page }) => {
await page.getByRole('menuitem', { name: 'Posters' }).click();
await page.getByRole('button', { name: category, exact: true }).click();
await expect(page).toHaveURL(/category_id/);
await expect(page.getByTestId('product-result-count')).toContainText('of 10');
await expect(page.getByRole('link', { name: new RegExp(`^${sample}`) })).toBeVisible();
});
}
});
Playwright 執行測試分成兩個階段:先載入測試檔、收集有哪些測試,再把測試分配給 worker 執行。迴圈寫在 test() 外面時,它在「收集」階段就跑完了,每一圈都呼叫一次 test(),等於註冊了一支新的測試。所以這段程式碼註冊的是 5 支獨立的測試,而不是 1 支。
可以用 --list 確認,它只列出收集到的測試,不會真的執行:
npx playwright test day24-parameterize --list --project=chromium
商品分類篩選 › 分類 Animals 顯示 10 筆且包含 Cat Nose
商品分類篩選 › 分類 Beard 顯示 10 筆且包含 Black Auburn
商品分類篩選 › 分類 Cars 顯示 10 筆且包含 Old Combi
商品分類篩選 › 分類 Food 顯示 10 筆且包含 Fuzzy Forks
商品分類篩選 › 分類 Tech 顯示 10 筆且包含 Black Screen
另外外層的 test.describe 其實不是必要的,但它可以把同一組資料產生的測試包在一起,報告裡看起來比較整齊。
補充:
for...of或categories.forEach(...)都可以,只要迴圈在test()外面就好。這篇用for...of,是因為它跟 Worker 那篇的寫法一致。
變成獨立的測試之後,前面三個問題都解決了,大家可以自己嘗試用前面及這篇的程式碼及指令確認下面的比較結果:
| 迴圈在 test 裡 | 迴圈在 test 外 | |
|---|---|---|
| 一筆失敗 | 後面的資料都不跑 | 只有那一支失敗,其他照跑 |
| 報告 | 1 支測試,要點進去才知道哪筆壞 | 每筆資料一支,看標題就知道 |
| 平行執行 | 同一個 worker 依序跑(對playwright來說只有一隻測試) | 可以分到不同 worker (對playwright來說有五隻測試) |
| 重試 | 整組重跑 | 只重跑失敗的那一筆 |
前面的文章還沒實際示範過重試,這裡剛好稍微說明一下怎麼使用。
本來專案的 config 是 retries: process.env.CI ? 2 : 0,也就是本機預設不重試。想在本機觀察,可以在指令上加 --retries=1 暫時開啟,不用改 config。另外 config 的 reporter 是 html,終端機不會逐筆印出結果,所以再加上 --reporter=list:
npx playwright test day24-parameterize -g "商品分類篩選" --project=chromium --retries=1 --reporter=list
先把資料表裡的一筆暫時改錯,例如把 Beard 的 sample 改成 'Black Auburn test wrong',再執行上面的指令。
正解(迴圈在 test 外):只有 Beard 那一支會重跑。除了看終端機的 retry #1,也可以直接看 test-results 資料夾,重跑過的測試會多一個 -retry1 結尾的資料夾:
test-results/
├── day24-parameterize-商品分類篩選--4288e-且包含-Black-Auburn-test-wrong-chromium/
│ ├── error-context.md
│ └── video.webm
└── day24-parameterize-商品分類篩選--4288e-且包含-Black-Auburn-test-wrong-chromium-retry1/
├── error-context.md
├── trace.zip
└── video.webm
其他四支分類都沒有 -retry1 資料夾,代表它們只跑了一次。
這裡還有一個小彩蛋:trace.zip 只出現在 -retry1 裡。這就是之前講除錯產出物時提過的 trace: 'on-first-retry'——第一次失敗不留 trace,重試時才錄。本機預設不重試,所以平常失敗時都拿不到 trace;加上 --retries=1 就能拿到了。
反例(迴圈在 test 裡):在迴圈裡加一行 console.log 印出目前檢查的分類,用同樣的方式執行:
npx playwright test day24-parameterize -g "反例" --project=chromium --retries=1 --reporter=list
重試時會從第一筆 Animals 重新開始,已經通過的資料也要再跑一次,因為對playwright來說其實只有一個測試,所以迴圈會全部都重跑一次。
仔細看 categories 裡的每一筆資料,它其實有兩種角色:
category:輸入,決定要點哪個分類sample:預期值,決定要斷言看到什麼參數化測試的資料表通常都長這樣。只放輸入不放預期值的話,每筆資料只能用同一個斷言,例如「列表不是空的」,這種驗證的意義就偏低。
預期值要選穩定的資料。這個 demo 的資料是 data-generator-retail 在瀏覽器端產生的,有一部分每次載入都是隨機的,例如庫存、價格。但分類的部分是固定的:每個分類剛好 10 張海報,品名也是寫死在產生器裡的(Animals 分類一定有 Cat Nose)。所以這裡斷言「顯示 10 筆」、「包含某個品名」,每次跑的結果都會一樣。
如果把隨機產生的價格當成預期值寫進資料表,測試就會時好時壞。使用參數化的重點是輸入及預期值都要選用穩定不會每次跑測試都會變動的值。
迴圈產生測試時,標題一定要跟著資料變。如果把標題寫死:
for (const { category, sample } of categories) {
test('分類篩選', async ({ loggedInPage: page }) => {
// ...
});
}
Playwright 在收集階段就會直接報錯,一支測試都不會跑:
Error: duplicate test title "分類篩選", first declared in day24-parameterize.spec.ts:2
同一個檔案裡的測試標題必須唯一,因為 Playwright 要用標題來分辨每一支測試。如果標題可以重複,報告裡出現五個「分類篩選」,其中一個失敗了,也不知道是哪一個出問題,這樣對除錯來說會造成困擾。
所以標題至少要放入能區分資料的欄位。更好的做法是讓標題直接說出「測的是什麼、預期什麼」:
| 標題 | 問題 |
|---|---|
分類篩選 |
重複,直接報錯 |
分類篩選 ${index} |
不重複,但「分類篩選 3」是哪個分類?要回頭數資料表 |
分類 ${category} |
知道是哪筆資料,但不知道預期什麼 |
分類 ${category} 顯示 10 筆且包含 ${sample} |
看報告就知道哪筆資料、預期什麼 |
最後一種寫法,在報告看到「分類 Cars 顯示 10 筆且包含 Old Combi」失敗時,不用打開程式碼,就大概知道要去查什麼。
這篇只用了最基本的「陣列+迴圈」。官方文件還介紹了其他做法,這裡簡單整理成表格,有需要可以再去查看谷氨方文件:
| 做法 | 適合的情境 |
|---|---|
| 資料寫在陣列裡,迴圈產生測試(本篇) | 資料不多、跟測試放在一起比較好讀 |
| 從 JSON/CSV 檔讀資料再跑迴圈 | 資料多、或需要由非工程師維護 |
| 參數化 project(在 config 的 project 設定不同參數) | 同一組測試要用不同設定整組跑一次,例如不同語系、不同帳號 |
詳細寫法可以參考官方文件:Parameterize tests。
參數化測試的重點只有一個:迴圈要寫在 test() 外面。這樣每筆資料會在收集階段各自變成一支獨立的測試,可以各自失敗、各自重試、平行執行。
設計資料表時,每筆資料同時放「輸入」和「預期值」,而且預期值要選每次都一樣的資料。測試標題要放入能區分資料的欄位,最好也放預期結果,這樣不需點進去報告就能一眼知道這個測試的案例跟預期結果是什麼。
下一篇會進入網路攔截與 Mock API,看看怎麼控制測試拿到的資料。