前面我們用 Fixture 處理登入,在每支測試開始前,fixture 會先幫它登入好,測試裡不用再重複寫登入步驟。不過在這個做法下,每一支測試還是各自登入了一次。
今天要介紹另一種方式可以讓所有測試只登入一次,之後每支測試一打開就是已登入的狀態,要做到這件事,需要用到 Playwright 的 storageState,再搭配 setup,在所有測試開始前先完成登入。
在 Playwright 的架構下,登入後的狀態(cookie、localStorage)會儲存在 context 裡,而 page 只是 context 底下的分頁。
storageState 做的事情就是:
載入時,每個 context 拿到的都是檔案內容的複本。測試中對 localStorage 做的任何修改只留在自己的 context 裡,不會寫回檔案,也不會影響其他測試。
以 react-admin demo 為例,登入後存出來的檔案大概長這樣:
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:8000",
"localStorage": [
{ "name": "username", "value": "demo" }
]
}
]
}
cookies 是空的,因為這個 demo 的登入是前端模擬的,但在有真實後端的專案,登入狀態通常是存在 cookie 裡的 session 或 token,所以 cookies 不會是空的。
打開 authProvider.ts 可以看到,登入後只做了一件事:
localStorage.setItem('username', username);
之後判斷是否已登入,會檢查 localStorage 裡有沒有 username。
了解了 storageState,接下來的問題是:這份檔案要由誰、在什麼時候產生?
答案是在所有測試開始之前,先跑一次登入,產生 storageState 的檔案,這個測試前的準備步驟就是 setup。
要讓 setup 在所有測試之前執行,要透過 project 來設定。
在 playwright.config.ts 中,有一段 projects 設定:
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
一個 project 就是「一組測試 + 一組執行設定」。預設的三個 project 設定的是不同瀏覽器,所以同一支測試會在 Chromium、Firefox、WebKit 各跑一次。之前執行指令時加的 --project=chromium,就是指定只跑 chromium 這個 project。
project 除了可以設定瀏覽器,還能設定:
testMatch)dependencies)use,例如今天的 storageState)詳細做法如下:
setup 的 project,它只負責執行登入的那支檔案dependencies: ['setup'],意思是「這個 project 要等 setup 跑完才開始」storageState,讓裡面的每支測試都載入 setup 產生的檔案執行順序會變成這樣:
setup(登入一次,存成 user.json)
│
├──> chromium(每支測試載入 user.json)
├──> firefox (每支測試載入 user.json)
└──> webkit (每支測試載入 user.json)
setup 跑完、檔案產生之後,三個瀏覽器的測試才平行開始,每支測試開新的 context 時載入同一份檔案。
補充:Playwright 另外還有一個
globalSetup設定,也能在測試前執行一段程式。不過它是在測試框架之外執行,報告裡看不到它的執行過程,也不能使用 fixture,官方文件目前較推薦的是今天介紹的 setup project 寫法。
在 tests/ 底下新增 auth.setup.ts,登入的部分直接沿用之前寫好的 LoginPage:
import { test as setup, expect } from '@playwright/test';
import path from 'path';
import { LoginPage } from '../pages/LoginPage';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('登入並儲存 storageState', async ({ page }) => {
console.log('>>> setup:執行登入流程');
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
// 確認登入完成才存檔
await expect(
page.getByRole('heading', { name: 'Welcome to the react-admin e-commerce demo' })
).toBeVisible();
await page.context().storageState({ path: authFile });
});
你可能注意到第一行寫的是 test as setup,這裡的 setup 其實就是平常寫測試用的那個 test,只是改名叫 setup,讓人一眼看出這支檔案是用來做準備工作的。寫成 test('登入並儲存 storageState', ...) 效果也完全一樣。
換句話說,auth.setup.ts 本身就是一支普通的測試檔,所以跟一般測試一樣能使用 page 這些 fixture,執行過程也會出現在報告裡。它之所以會在其他測試之前執行,靠的不是檔案寫法,而是靠 config 裡的設定。
在 playwright.config.ts 的 projects 裡加上 setup project,並在原本三個瀏覽器的 project 加上 dependencies 與 storageState:
import path from 'path';
const authFile = path.join(__dirname, 'playwright/.auth/user.json');
export default defineConfig({
// ...其他設定不變
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: authFile },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: authFile },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: authFile },
dependencies: ['setup'],
},
],
});
上面設定檔的意義如下:
testMatch:setup project 只執行檔名是 .setup.ts 結尾的檔案dependencies: ['setup']:這個 project 要等 setup 跑完才開始storageState:這個 project 的每個 context 都載入這份檔案現在測試裡不需要任何登入 fixture,直接使用內建的 page,打開就是已登入的狀態:
import { test, expect } from '@playwright/test';
import { ProductsPage } from '../pages/ProductsPage';
test('直接進商品列表,不會被導到登入頁', async ({ page }) => {
await page.goto('/#/products');
await expect(page.getByRole('button', { name: 'Sign in' })).not.toBeVisible();
await expect(page).toHaveURL(/#\/products/);
});
test('巡檢 1:搜尋 Black', async ({ page }) => {
await page.goto('/');
const productsPage = new ProductsPage(page);
await productsPage.goto();
await productsPage.search('Black');
await expect(productsPage.resultCount).toBeVisible();
});
test('巡檢 2:商品列表預設顯示 Aerial Coast', async ({ page }) => {
await page.goto('/');
const productsPage = new ProductsPage(page);
await productsPage.goto();
await expect(productsPage.productCard('Aerial Coast')).toBeVisible();
});
巡檢 1、2 是之前緩衝日實驗用過的測試,當時因為共用 context,巡檢 1 的搜尋條件會汙染巡檢 2。今天拿來確認改用 storageState 後,測試之間是否互相獨立。
用一個 worker 執行,讓三支測試依序在同一個 worker 裡跑,重現當時共用 context 被汙染的條件:
npx playwright test day26-storage-state --project=chromium --workers=1
Running 4 tests using 1 worker
[setup] › tests/auth.setup.ts:7:6 › 登入並儲存 storageState
>>> setup:執行登入流程
4 passed (5.9s)
可以看到:
>>> setup:執行登入流程 只印一次,後面三支測試都沒有再登入Black 時寫進 localStorage 的篩選條件,只留在巡檢 1 自己的 context 裡;巡檢 2 開新的 context 時,載入的是 user.json 的乾淨複本,所以列表不會被篩選再做一個小實驗:把巡檢 2 的 'Aerial Coast' 暫時改成 'NotExist' 讓它失敗,打開 HTML 報告,會看到巡檢 2 有一支屬於自己的影片,而且影片從首頁開始,沒有登入過程。整個過程我們沒有寫任何處理影片的程式碼。
不過要注意,storageState 隔離的是瀏覽器裡的狀態。如果測試是在後端資料庫新增了一筆資料,每個 context 重新載入時都還是會看到它,這部分需要在測試結束後清理,後面介紹 Global Teardown 時會再處理。
1. 存檔前要確認登入已經完成: 如果按下登入後馬上存檔,可能在登入狀態還沒寫進去之前就存了,得到一份「未登入」的檔案。所以 setup 裡先用 expect 確認進到後台,再呼叫 storageState()。
2. setup 只做登入,不要順便做別的事: storageState 會把當下 localStorage 的內容全部存下來。react-admin 會把列表的篩選、排序等狀態也存在 localStorage,如果 setup 登入後順手逛了列表頁、做了搜尋,這些狀態也會被存進檔案,這樣之後每一支測試打開也會帶著這些狀態,造成測試不獨立。
context.storageState({ path }) 存,在 config 的 use.storageState 載入。這個 demo 只存了 localStorage 裡的 username,真實專案通常是 cookie。登入只做一次之後,接下來要面對的是:如果不同測試需要不同身分的使用者,例如管理員和一般使用者,storageState 要怎麼管理?下一篇我們來看多角色與多帳號的做法。