上一篇用 setup project 搭配 storageState,讓所有測試只登入一次。不過目前所有測試用的都是同一個身分:demo。
實際上一個系統通常不會只有一種使用者。管理員和一般使用者看到的權限功能不同,有些功能未登入的使用者不能操作,這些都是測試可能需要驗證的範圍;另外,平行執行時,多個 worker 如果同時用同一個帳號操作系統,也可能會互相影響。
今天我們就來處理這兩種情況:
在真實專案中,各角色的測試帳號通常事先由後台或測試環境的初始資料(seed)建好,測試程式只負責用這些帳號登入。
我們目前使用的這個 demo 不檢查帳密,從 demo/src/authProvider.ts 可以看到 login 只把 username 存進 localStorage,不管輸入什麼帳密都能登入;右上角顯示的使用者名稱也寫死成 Jane Doe,所以我們要先修改 demo 的原始碼,讓它有真正的帳號設定。
在 demo/src/authProvider.ts 加入帳號清單,並修改 login 與 getIdentity,其他部分不用動,avatar 那一長串保持原本的值:
type DemoUser = { password: string; fullName: string };
// 測試用帳號清單:要先在這裡設定好,才能登入
const users: Record<string, DemoUser> = {
admin: { password: 'admin', fullName: 'Admin User' },
demo: { password: 'demo', fullName: 'Jane Doe' },
// worker 專屬帳號,後面會用到
'worker-0': { password: 'worker', fullName: 'Worker 0' },
'worker-1': { password: 'worker', fullName: 'Worker 1' },
};
const authProvider: AuthProvider = {
login: ({ username, password }) => {
const user = users[username];
if (!user || user.password !== password) {
return Promise.reject(new Error('帳號或密碼錯誤'));
}
localStorage.setItem('username', username);
return Promise.resolve();
},
// ...logout、checkError、checkAuth、getPermissions 不變
getIdentity: () => {
const username = localStorage.getItem('username') ?? '';
return Promise.resolve({
id: username,
fullName: users[username]?.fullName ?? '',
avatar: '...', // 保持原本的值
});
},
};
改了兩個地方:
login:只有清單裡的帳號、而且密碼正確才能登入,否則回傳錯誤getIdentity:右上角顯示的名稱改成依帳號而定,admin 會顯示 Admin User
改完後重新啟動 demo,手動確認:
admin / admin 登入,右上角顯示 Admin User
demo / demo 登入,右上角顯示 Jane Doe
abc / abc 登入,會被擋下來帳號準備好之後,我們需要為每個角色各登入一次,並儲存成各自的 storageState。
修改 Day26 的 tests/auth.setup.ts,把登入存檔的步驟抽成一個函式,分別給一般使用者和管理員使用:
import { test as setup, expect, type Page } from '@playwright/test';
import path from 'path';
import { LoginPage } from '../pages/LoginPage';
const userFile = path.join(__dirname, '../playwright/.auth/user.json');
const adminFile = path.join(__dirname, '../playwright/.auth/admin.json');
async function loginAndSave(page: Page, username: string, password: string, file: string) {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(username, password);
// 確認登入完成才存檔
await expect(
page.getByRole('heading', { name: 'Welcome to the react-admin e-commerce demo' })
).toBeVisible();
await page.context().storageState({ path: file });
}
setup('登入一般使用者', async ({ page }) => {
console.log('>>> setup:demo 登入');
await loginAndSave(page, 'demo', 'demo', userFile);
});
setup('登入管理員', async ({ page }) => {
console.log('>>> setup:admin 登入');
await loginAndSave(page, 'admin', 'admin', adminFile);
});
跑完 setup 後,playwright/.auth/ 底下會有兩個檔案:
playwright/.auth/
├── user.json ← username: demo
└── admin.json ← username: admin
config 不需要改。Day26 設定的 storageState: authFile 指向的還是 user.json,這代表沒有特別指定的測試,預設都是一般使用者。
把三種身分放在同一個測試檔裡比對:預設的一般使用者、管理員,以及未登入的使用者。
tests/day27-multi-role.spec.ts
import { test, expect } from '@playwright/test';
test.describe('預設身分:一般使用者', () => {
test('右上角顯示 Jane Doe', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: 'Profile' })).toContainText('Jane Doe');
});
});
test.describe('管理員', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });
test('右上角顯示 Admin User', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: 'Profile' })).toContainText('Admin User');
});
});
test.describe('未登入', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('直接進商品列表會被導到登入頁', async ({ page }) => {
await page.goto('/#/products');
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
});
storageState 是整個 project 的預設身分;寫在 describe 裡的 test.use({ storageState }) 只會讓那個 describe 的測試換成另一個身分,如果是寫在檔案最外層則是整個檔案都換。storageState 除了給檔案路徑,也可以直接給物件;傳入空的 cookies、origins,就代表什麼登入狀態都沒有。Profile(react-admin 的預設 aria-label),所以先用 getByRole('button', { name: 'Profile' }) 找到按鈕,再用 toContainText 確認名稱。執行:
npx playwright test day27-multi-role --project=chromium
結果:
Running 5 tests using 3 workers
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
5 passed (3.6s)
多角色解決的是「不同測試需要不同身分」。接下來要處理的是另一個問題:同一個身分,被好幾個 worker 同時使用。
前面提過,平行執行時會有好幾個 worker 同時跑測試。目前所有 worker 載入的都是同一份 user.json,等於好幾個人同時登入同一個帳號在操作系統。
storageState 隔離的是瀏覽器裡的狀態(localStorage、cookie);但記在後端、綁在帳號上的資料,每個 worker 看到的都是同一份。舉個例子:
兩支測試單獨跑都會過,同時跑就可能會有衝突,導致測試結果時好時壞。因此我們需要讓每個 worker 使用不同且是自己的專屬帳號進行測試來避免這個狀況。
要讓 worker 對應到屬於自己的帳號,需要一個編號。前面介紹 Worker 機制時用過 workerIndex,不過這裡要用的是 parallelIndex,兩者的差別在於:
workerIndex |
parallelIndex |
|
|---|---|---|
| 編號範圍 | 0、1、2、3…持續增加 | 固定在 0 ~ workers - 1 |
| 測試失敗、worker 重啟後 | 新的 worker 拿到新的編號 | 沿用原本的編號 |
測試失敗時,Playwright 會關掉那個 worker、再開一個新的 worker 繼續跑剩下的測試。用 parallelIndex 對應帳號,不管重啟幾次編號都不會超出範圍,所以帳號數量只需要等於 worker 數。
做法是寫一個 worker-scoped fixture:每個 worker 第一次需要時,用自己的帳號登入並存檔,之後這個 worker 裡的測試都載入這份檔案。
fixtures/worker-auth.ts
import { test as baseTest, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { LoginPage } from '../pages/LoginPage';
export const test = baseTest.extend<{}, { workerStorageState: string }>({
// 覆寫內建的 storageState:改用這個 worker 自己的檔案
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [async ({ browser }, use, workerInfo) => {
const id = workerInfo.parallelIndex;
const fileName = path.resolve(workerInfo.project.outputDir, `.auth/worker-${id}.json`);
// 這個 worker 已經登入過,直接沿用
if (fs.existsSync(fileName)) {
await use(fileName);
return;
}
const username = `worker-${id}`;
console.log(`>>> parallelIndex ${id}:用 ${username} 登入`);
// 自己開的 page 不會套用 config 的設定,baseURL 要自己給
const page = await browser.newPage({
storageState: undefined,
baseURL: workerInfo.project.use.baseURL,
});
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(username, 'worker');
await expect(
page.getByRole('heading', { name: 'Welcome to the react-admin e-commerce demo' })
).toBeVisible();
await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
}, { scope: 'worker' }],
});
export { expect };
這段程式有三個重點:
workerStorageState(worker-scoped):每個 worker 只執行一次,用 parallelIndex 組出帳號名稱 worker-0、worker-1,登入後存成檔案;檔案已經存在(例如 worker 重啟時)就直接沿用。storageState:storageState 原本是從 config 取值的內建選項,這裡把它改成「使用這個 worker 的檔案」,所以測試裡的 page 一打開就是這個 worker 的帳號。baseURL:browser.newPage() 不會套用 config 的 use 設定。另外檔案存在 test-results/ 底下,這個資料夾每次執行前會被清空,所以每次執行時每個 worker 都會重新登入一次。tests/day27-worker-account.spec.ts
import { test, expect } from '../fixtures/worker-auth';
for (const n of [1, 2, 3, 4]) {
test(`巡檢 ${n}:確認自己是哪個帳號`, async ({ page }, testInfo) => {
await page.goto('/');
const expected = `Worker ${testInfo.parallelIndex}`;
console.log(`巡檢 ${n} → parallelIndex ${testInfo.parallelIndex},預期帳號 ${expected}`);
await expect(page.getByRole('button', { name: 'Profile' })).toContainText(expected);
});
}
注意第一行是從自己寫的 fixtures/worker-auth import test,而不是 @playwright/test,這樣才會用到覆寫過的 storageState。
用兩個 worker 執行:
npx playwright test day27-worker-account --project=chromium --workers=2
預期結果:
Running 6 tests using 2 workers
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[chromium] › tests/day27-worker-account.spec.ts:4:9 › 巡檢 2:確認自己是哪個帳號
>>> parallelIndex 1:用 worker-1 登入
[chromium] › tests/day27-worker-account.spec.ts:4:9 › 巡檢 1:確認自己是哪個帳號
>>> parallelIndex 0:用 worker-0 登入
[chromium] › tests/day27-worker-account.spec.ts:4:9 › 巡檢 2:確認自己是哪個帳號
巡檢 2 → parallelIndex 1,預期帳號 Worker 1
[chromium] › tests/day27-worker-account.spec.ts:4:9 › 巡檢 1:確認自己是哪個帳號
巡檢 1 → parallelIndex 0,預期帳號 Worker 0
[chromium] › tests/day27-worker-account.spec.ts:4:9 › 巡檢 3:確認自己是哪個帳號
巡檢 3 → parallelIndex 1,預期帳號 Worker 1
[chromium] › tests/day27-worker-account.spec.ts:4:9 › 巡檢 4:確認自己是哪個帳號
巡檢 4 → parallelIndex 0,預期帳號 Worker 0
6 passed (5.3s)
可以看到:
用 worker-0 登入、用 worker-1 登入 各只出現一次parallelIndex 對得上(setup 的兩支仍然會先跑,因為 chromium project 設定了 dependencies: ['setup']。)
把 worker 數改成 3:
npx playwright test day27-worker-account --project=chromium --workers=3
結果測試失敗,主要是因為 parallelIndex 為 2 的 worker 會用 worker-2 登入,但帳號清單裡沒有這個帳號,登入被擋下來,fixture 等不到歡迎標題,分到這個 worker 的測試因此全部失敗。
所以使用 worker 專屬帳號的前提是帳號數量必須大於等於 worker 數量,因此實務上通常會在 config 裡明確設定 workers,並準備對應數量的帳號。
帳號資訊不要寫死在測試程式裡: 今天為了示範,把帳密直接寫在 setup 和 fixture 裡。實際專案中,測試帳號的密碼通常放在 .env 或 CI 的環境變數,測試程式再從 process.env 讀取,不然會有帳密外洩的問題。
storageState 是預設身分,test.use({ storageState }) 能夠覆寫預設的身份,因應不同測試需要不同身份的需求。{ cookies: [], origins: [] }表示沒有登入的資訊。parallelIndex,讓每個 worker 用自己的帳號登入,且準備的帳號數量要大於等於 worker 數。我們現在已經可以用 setup 在測試前把登入狀態準備好,但測試跑完之後,留下的 .auth 檔、測試過程產生的資料,又該怎麼辦呢?下一篇會介紹怎麼使用 Global Teardown,把測試前的準備與測試產生的資料清理乾淨。