前面五篇我們依序介紹了參數化測試、Global Setup 與 storageState、多角色與 worker 專屬帳號,以及 Global Teardown。今天是最後一個緩衝日,我們用一個常見的情境把它們串起來,並在最後做個小補充:角色應該放在哪一層?
每次上版前,想確認後台幾個主要列表頁都能正常打開,而且不同身分的人進去,看到的身分是對的;沒登入的人直接打網址,要被擋回登入頁。
具體要巡檢的範圍:
| 身分 | 要確認的事 |
|---|---|
一般使用者(demo) |
商品、訂單、客戶、評論 4 個列表頁都能打開,右上角顯示 Jane Doe |
管理員(admin) |
同樣 4 個列表頁,右上角顯示 Admin User |
| 未登入 | 直接進這 4 個網址,都會被導到登入頁 |
3 種身分 × 4 個頁面,總共 12 支測試,但我們希望在 setup 階段就把登入的操作處理完,測試本身只要專注在目標功能確認就好。
參數化測試時提過,一筆資料應該包含「輸入」和「預期」。這裡的輸入是頁面網址,預期則是該頁面有確實載入完成的結果。
每個列表頁長得不一樣,能當作「載入完成」證據的元素也不同,所以把它也寫進資料表:
type Route = { name: string; path: string; ready: (page: Page) => Locator };
const routes: Route[] = [
{ name: '商品列表', path: '/#/products', ready: (p) => p.getByTestId('product-result-count') },
{ name: '訂單列表', path: '/#/orders', ready: (p) => p.getByRole('tab', { name: /^ordered/i }) },
{ name: '客戶列表', path: '/#/customers', ready: (p) => p.getByRole('columnheader', { name: /Last seen/i }) },
{ name: '評論列表', path: '/#/reviews', ready: (p) => p.getByRole('columnheader', { name: /Product/i }) },
];
product-result-count。ordered (N)、delivered (N)、cancelled (N) 三個分頁,N 是筆數,所以用正規表示式只比對開頭。ready 寫成函式而不是直接放 Locator,這是因為 Locator 需要 page 才能建立,而資料表定義的時候還沒有 page。
角色的部分沿用多角色那篇的做法:test.use({ storageState }) 寫在 describe 裡,切換這一組測試的身分。外層跑角色、內層跑頁面:
tests/day29-patrol.spec.ts
import { test, expect, type Page, type Locator } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const tmpDir = path.join(__dirname, '../test-data/tmp');
type Route = { name: string; path: string; ready: (page: Page) => Locator };
const routes: Route[] = [
{ name: '商品列表', path: '/#/products', ready: (p) => p.getByTestId('product-result-count') },
{ name: '訂單列表', path: '/#/orders', ready: (p) => p.getByRole('tab', { name: /^ordered/i }) },
{ name: '客戶列表', path: '/#/customers', ready: (p) => p.getByRole('columnheader', { name: /Last seen/i }) },
{ name: '評論列表', path: '/#/reviews', ready: (p) => p.getByRole('columnheader', { name: /Product/i }) },
];
const roles = [
{ role: '一般使用者', storageState: 'playwright/.auth/user.json', profile: 'Jane Doe' },
{ role: '管理員', storageState: 'playwright/.auth/admin.json', profile: 'Admin User' },
];
for (const r of roles) {
test.describe(`[${r.role}]`, () => {
test.use({ storageState: r.storageState });
for (const route of routes) {
test(`巡檢 ${route.name}`, async ({ page }, testInfo) => {
await page.goto(route.path);
// 身分正確
await expect(page.getByRole('button', { name: 'Profile' })).toContainText(r.profile);
// 頁面真的載入完成
await expect(route.ready(page)).toBeVisible();
// 留下一筆巡檢紀錄
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, `patrol-w${testInfo.parallelIndex}-${Date.now()}.txt`),
`${r.role},${route.path},ok`
);
});
}
});
}
test.describe('[未登入]', () => {
test.use({ storageState: { cookies: [], origins: [] } });
for (const route of routes) {
test(`直接進 ${route.name} 會被導到登入頁`, async ({ page }) => {
await page.goto(route.path);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
}
});
幾個跟前面幾篇對應的地方:
test() 外面:每筆資料各自是一支測試,其中一頁壞掉,不會讓其他頁面的巡檢跟著停下來。describe 標題,頁面放在 test 標題:報告裡會顯示成 [管理員] › 巡檢 訂單列表,哪個身分的哪一頁失敗一眼就看得出來。user.json、admin.json 都是 setup 產生的,測試只負責「載入」。未登入則直接給空的 storageState。parallelIndex 和時間戳記,平行執行時不會撞名。這份紀錄本身沒什麼用途,主要是讓今天的流程有「測試過程產生的檔案」需要收尾。playwright.config.ts、auth.setup.ts、global.teardown.ts 都沿用前幾篇的版本:
demo、admin,各存一份 storageStatedependencies: ['setup'] 確保 setup 先跑完teardown: 'teardown',所有測試跑完後,teardown 會清掉 test-data/tmp 和 playwright/.auth
先用 --list 看會產生哪些測試:
npx playwright test day29-patrol --project=chromium --list
Listing tests:
[setup] › auth.setup.ts:41:6 › 登入一般使用者
[setup] › auth.setup.ts:46:6 › 登入管理員
[teardown] › global.teardown.ts:9:9 › 清除暫存檔
[teardown] › global.teardown.ts:14:9 › 清除登入狀態檔
[chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 商品列表
[chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 訂單列表
[chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 客戶列表
[chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 評論列表
[chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 商品列表
[chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 訂單列表
[chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 客戶列表
[chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 評論列表
[chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 商品列表 會被導到登入頁
[chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 訂單列表 會被導到登入頁
[chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 客戶列表 會被導到登入頁
[chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 評論列表 會被導到登入頁
Total: 16 tests in 3 files
預期會看到 2 支 setup、12 支巡檢,以及 2 支 teardown,且巡檢測試的標題會帶有 [角色] 前綴。
正式執行:
npx playwright test day29-patrol --project=chromium
Running 16 tests using 4 workers
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[teardown] › tests/global.teardown.ts:14:9 › 清除登入狀態檔
>>> teardown:清除 .auth
[teardown] › tests/global.teardown.ts:9:9 › 清除暫存檔
>>> teardown:清除 test-data/tmp
16 passed (7.5s)
從輸出確認三件事:
>>> setup:demo 登入、>>> setup:admin 登入 各只出現一次,即使 12 支測試分散在好幾個 worker 上跑>>> teardown,且在跑完後 test-data/tmp 和 playwright/.auth 都不見了上面的寫法把角色放在 spec 檔裡(describe + test.use),但其實還有另一種常見的做法是把角色放在 config 裡,每個角色各開一個 project。
在 playwright.config.ts 的 projects 加上兩個角色 project:
{
name: 'role-user',
testMatch: /day29-patrol-by-project/,
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'role-admin',
testMatch: /day29-patrol-by-project/,
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' },
dependencies: ['setup'],
},
原本的 chromium、firefox、webkit 也會掃到這支新的 spec,所以要在這三個 project 加上 testIgnore: /day29-patrol-by-project/,避免同一支測試又用預設身分多跑一次。
採用這個做法,本來的測試檔案就不用再寫角色迴圈了,只剩頁面迴圈。而測試要知道自己是哪個角色,可以從 testInfo.project.name 取得:
tests/day29-patrol-by-project.spec.ts
import { test, expect, type Page, type Locator } from '@playwright/test';
// routes 與 day29-patrol.spec.ts 相同,這裡省略
const profileByProject: Record<string, string> = {
'role-user': 'Jane Doe',
'role-admin': 'Admin User',
};
for (const route of routes) {
test(`巡檢 ${route.name}`, async ({ page }, testInfo) => {
await page.goto(route.path);
await expect(page.getByRole('button', { name: 'Profile' }))
.toContainText(profileByProject[testInfo.project.name]);
await expect(route.ready(page)).toBeVisible();
});
}
我們嘗試只跑一個角色看看會發生什麼事:
npx playwright test --project=role-admin
Running 8 tests using 4 workers
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
[teardown] › tests/global.teardown.ts:14:9 › 清除登入狀態檔
>>> teardown:清除 .auth
[teardown] › tests/global.teardown.ts:9:9 › 清除暫存檔
>>> teardown:清除 test-data/tmp
8 passed (4.5s)
從上面的結果可以發現setup 登入了admin跟demo,因為我們剛剛雖然指定了要跑的project,但是setup維持了本來admin跟一般使用者的登入操作,所以setup + teardown 總共是4支測試,剩下的4支就是這次跑的project裡面要驗證的四個頁面了。
A:describe + test.use |
B:每個角色一個 project | |
|---|---|---|
| 角色寫在哪 | spec 檔 | config |
| 報告標題 | [chromium] › … › [管理員] › 巡檢 訂單列表 |
[role-admin] › … › 巡檢 訂單列表 |
| 只跑某個角色 | -g "管理員" |
--project=role-admin |
| 新增一個角色 | roles 陣列多一筆 |
config 多一個 project,對照表多一筆 |
| 跟瀏覽器的關係 | project 仍然是瀏覽器,互不影響 | 想同時測多個瀏覽器時,角色 × 瀏覽器的 project 數會相乘 |
主要還是看你的測試目的:
以今天的巡檢來說,只有兩個角色、而且只有這一支 spec 需要切換身分,寫法 A 就足夠了。等到角色變多、需要每個角色都完整跑過一輪時,再考慮換成寫法 B。
test() 外面。describe + test.use 切換身分,未登入也是一種角色。到這裡,30 天的技術內容就全部介紹完了。明天是最後一天,我們會一起回顧這段時間學到了什麼,以及還有哪些能繼續深入的地方。