iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
自我挑戰組

Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記系列 第 27 篇

Day 27 - storageState 進階:多角色與 worker 專屬帳號

  • 分享至 

  • xImage
  •  

上一篇用 setup project 搭配 storageState,讓所有測試只登入一次。不過目前所有測試用的都是同一個身分:demo。

實際上一個系統通常不會只有一種使用者。管理員和一般使用者看到的權限功能不同,有些功能未登入的使用者不能操作,這些都是測試可能需要驗證的範圍;另外,平行執行時,多個 worker 如果同時用同一個帳號操作系統,也可能會互相影響。

今天我們就來處理這兩種情況:

  1. 多角色:不同身分各存一份 storageState,測試可以指定要用哪個身分
  2. worker 專屬帳號:每個 worker 用自己的帳號,避免平行執行時互相影響

前置作業:先在專案裡準備好帳號

在真實專案中,各角色的測試帳號通常事先由後台或測試環境的初始資料(seed)建好,測試程式只負責用這些帳號登入。
我們目前使用的這個 demo 不檢查帳密,從 demo/src/authProvider.ts 可以看到 login 只把 username 存進 localStorage,不管輸入什麼帳密都能登入;右上角顯示的使用者名稱也寫死成 Jane Doe,所以我們要先修改 demo 的原始碼,讓它有真正的帳號設定。

修改 authProvider:加入帳號清單

在 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: '...', // 保持原本的值
        });
    },
};

改了兩個地方:

  1. login:只有清單裡的帳號、而且密碼正確才能登入,否則回傳錯誤
  2. getIdentity:右上角顯示的名稱改成依帳號而定,admin 會顯示 Admin User

改完後重新啟動 demo,手動確認:

  • 用 admin / admin 登入,右上角顯示 Admin User
  • 用 demo / demo 登入,右上角顯示 Jane Doe
  • 用 abc / abc 登入,會被擋下來

多角色:每個角色各存一份 storageState

帳號準備好之後,我們需要為每個角色各登入一次,並儲存成各自的 storageState。

步驟一:setup 裡多登入一個角色

修改 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,這代表沒有特別指定的測試,預設都是一般使用者。

步驟二:用 test.use 切換身分

把三種身分放在同一個測試檔裡比對:預設的一般使用者、管理員,以及未登入的使用者。

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();
    });
});
  • 切換身分:config 的 storageState 是整個 project 的預設身分;寫在 describe 裡的 test.use({ storageState }) 只會讓那個 describe 的測試換成另一個身分,如果是寫在檔案最外層則是整個檔案都換。
  • 未登入:有些測試需要從「沒登入」開始,例如測登入流程本身。storageState 除了給檔案路徑,也可以直接給物件;傳入空的 cookies、origins,就代表什麼登入狀態都沒有。
  • 確認身分:右上角的使用者選單是一個按鈕,上面顯示的是登入者名稱,但它的 accessible name 是 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 同時跑測試。目前所有 worker 載入的都是同一份 user.json,等於好幾個人同時登入同一個帳號在操作系統。

storageState 隔離的是瀏覽器裡的狀態(localStorage、cookie);但記在後端、綁在帳號上的資料,每個 worker 看到的都是同一份。舉個例子:

  • worker 0 正在跑「加入商品,確認購物車有 1 件」
  • worker 1 同時在跑「清空購物車,確認購物車是 0 件」

兩支測試單獨跑都會過,同時跑就可能會有衝突,導致測試結果時好時壞。因此我們需要讓每個 worker 使用不同且是自己的專屬帳號進行測試來避免這個狀況。

parallelIndex:固定 worker 編號與對應的帳號

要讓 worker 對應到屬於自己的帳號,需要一個編號。前面介紹 Worker 機制時用過 workerIndex,不過這裡要用的是 parallelIndex,兩者的差別在於:

workerIndex parallelIndex
編號範圍 0、1、2、3…持續增加 固定在 0 ~ workers - 1
測試失敗、worker 重啟後 新的 worker 拿到新的編號 沿用原本的編號

測試失敗時,Playwright 會關掉那個 worker、再開一個新的 worker 繼續跑剩下的測試。用 parallelIndex 對應帳號,不管重啟幾次編號都不會超出範圍,所以帳號數量只需要等於 worker 數。

寫一個 worker 專屬帳號的 fixture

做法是寫一個 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 };

這段程式有三個重點:

  1. workerStorageState(worker-scoped):每個 worker 只執行一次,用 parallelIndex 組出帳號名稱 worker-0、worker-1,登入後存成檔案;檔案已經存在(例如 worker 重啟時)就直接沿用。
  2. 覆寫 storageState:storageState 原本是從 config 取值的內建選項,這裡把它改成「使用這個 worker 的檔案」,所以測試裡的 page 一打開就是這個 worker 的帳號。
  3. 自己開的 page 要自己給 baseURL:browser.newPage() 不會套用 config 的 use 設定。另外檔案存在 test-results/ 底下,這個資料夾每次執行前會被清空,所以每次執行時每個 worker 都會重新登入一次。

驗證 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)

可以看到:

  1. 每個 worker 只登入一次:用 worker-0 登入、用 worker-1 登入 各只出現一次
  2. 每支測試用的是自己 worker 的帳號:右上角顯示的名稱和 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 讀取,不然會有帳密外洩的問題。

小結

  1. 先有帳號,才有 storageState:storageState 只是把登入狀態存下來,各角色的帳號要先在系統裡準備好。
  2. 多角色:每個角色各存一份檔案,config 的 storageState 是預設身分,test.use({ storageState }) 能夠覆寫預設的身份,因應不同測試需要不同身份的需求。
  3. 未登入:傳入空的 storageState { cookies: [], origins: [] }表示沒有登入的資訊。
  4. worker 專屬帳號:用 worker-scoped fixture 搭配 parallelIndex,讓每個 worker 用自己的帳號登入,且準備的帳號數量要大於等於 worker 數。

我們現在已經可以用 setup 在測試前把登入狀態準備好,但測試跑完之後,留下的 .auth 檔、測試過程產生的資料,又該怎麼辦呢?下一篇會介紹怎麼使用 Global Teardown,把測試前的準備與測試產生的資料清理乾淨。


上一篇
Day 26 - Global Setup + storageState:讓登入只要做一次的方法
下一篇
Day 28 - Global Teardown:測試結束後的收尾
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言