上一篇我們介紹了 Worker 的運作原理:每個 Worker 都是獨立的 Node.js 程序,彼此的記憶體完全不共享;而 fullyParallel: true 會把分配的基準單位從「檔案」縮小到「單一測試」,所以同一個檔案裡的測試會被拆散到不同 Worker,執行順序也不保證。
但如果我不希望它們被拆散,該怎麼做呢?另外同一個 Worker 裡的測試,除了可以「排隊執行」之外,是否也能共用context跟page呢?
實務上偶爾會遇到跟平行化相反的情況,例如某幾支測試就是有先後順序,像是必須先建立一筆資料進db,後面才能編輯它、刪除它。這時候可以用 test.describe.configure() 把它們綁成一組:
test.describe('商品的建立與刪除', () => {
test.describe.configure({ mode: 'serial' });
test('建立一筆新商品', async ({ page }) => { /* ... */ });
test('編輯剛剛建立的商品', async ({ page }) => { /* ... */ });
test('刪除剛剛建立的商品', async ({ page }) => { /* ... */ });
});
mode: 'serial' 做了三件事:
fullyParallel 拆散。這邊比較特別的一點是,在 serial 的設定下,如果前面的測試失敗,後面的測試直接跳過不執行,想像一下如果「建立商品」這支測試掛了,後面的「編輯商品」或「刪除商品」操作也沒有意義。
此外,retry 的行為也不一樣:serial 群組是整組一起重試,不會只重跑失敗的那一支。畢竟後面的測試依賴前面的結果,單獨重跑也沒有意義。
不過Playwright 官方比較不推薦使用 serial,因為測試之間互相依賴本身就不是好設計。而上面那個例子更好的寫法,是讓「刪除商品」這支測試自己準備一筆要刪除的資料,不要依賴前一支測試留下的產物。不過實務上有些時候因為各種因素無法讓測試間沒有依賴性的話,serial 就是一個妥協方案。
serial 讓幾隻測試依照特定順序在一個worker上執行,但這些測試使用的依然是各自全新的 Page,每一支都得重新走一次登入流程。既然它們都在同一個 Worker 裡,能不能連瀏覽器的狀態也一起共用呢?當然可以,但我們先詳細了解一下各種不同的共用組合。
前面介紹 Fixture 進階時,我們寫了 workerLoggedInPage,讓整個 Worker 的測試共用同一個已登入的 Page,登入次數從三次降到一次。當時也提到共用是有代價的:測試之間會互相看到彼此留下的畫面狀態。
這邊先問一個基本的問題:我們共用的到底是什麼?
回億一下前面講過的三層結構:
Browser(瀏覽器程式本身)
│
└── Context(一個獨立的瀏覽器環境,像無痕視窗)
│ ▲ cookie、localStorage 存在這一層 → 登入狀態在這裡
│
├── Page(分頁 1)
└── Page(分頁 2)
當你在 Chrome 登入某個網站之後,開一個新分頁再連到同一個網站時,還是在登入狀態,因為登入資訊存在瀏覽器的設定檔裡,所以不需要重複登入。但如果你開的是無痕視窗,就得重新登入了,因為那是另一個隔離的環境。
Context 是那個「環境」,Page 是「分頁」。所以:
workerLoggedInPage 的共用狀態有了這個認識,回頭看我們自己寫的那個 Fixture:
workerLoggedInPage: [async ({ browser }, use) => {
const context = await browser.newContext({ baseURL: 'http://localhost:8001' });
const page = await context.newPage();
// ...登入...
await use(page); // 共用這個 page
await context.close();
}, { scope: 'worker' }],
這個 Fixture 叫做 workerLoggedInPage,從程式碼會發現,page 是從 context 開出來的,所以使用這個 Fixture 的測試,不只共用了 Page,其實也一起共用了 Context。而且這個開放給測試程式碼使用的其實是 page,這表示使用這個 Fixture 的測試,其實共享了上一個測試留下的 Page 狀態,而不是自己開新分頁。
| 誰被共用 | 登入次數 | 每支測試拿到的畫面 | |
|---|---|---|---|
A:內建 page(預設) |
都不共用 | 每支測試一次 | 全新 |
B:workerLoggedInPage |
Context 和 Page | 每個 Worker 一次 | 上一支測試留下的 |
| C:今天要做的 | 只有 Context | 每個 Worker 一次 | 全新分頁 |
| — | 這個組合不存在 | — |
D 選項在直覺上好像應該存在,但Playwright的設計上,Page 必定隸屬於某一個 Context,所以只要共用了 Page,就一定共用了Context,也就是上面的 B 這種情況,所以不存在 D 的情況。
B 跟 C 的差別在於,B把共用延伸到Page,也就是把Page狀態也共用了,所以測試之間會看到彼此的畫面狀態,C只到Context層級,每支測試拿到的是全新的分頁。
我們拆成兩個 Fixture:worker-scoped 的 sharedContext 負責建立一個「已經登入的環境」,每個 Worker 只跑一次;test-scoped 的 sharedPage 從那個環境裡開一個新分頁,每支測試各跑一次。
// fixtures/fixtures.ts
import { test as base, type Page, type BrowserContext } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type TestFixtures = {
// ...原有的 fixture...
sharedPage: Page;
};
type WorkerFixtures = {
workerLoggedInPage: Page;
sharedContext: BrowserContext;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
// ...原有的 fixture 省略...
sharedContext: [async ({ browser }, use) => {
console.log('>>> 執行登入流程');
// 手動建的 context 不會繼承 config 的 use 設定,baseURL 要自己重新給
const context = await browser.newContext({
baseURL: 'http://localhost:8000',
});
// 開一個分頁做登入——登入狀態會寫進 context 的 localStorage
const page = await context.newPage();
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
await page.close(); // 登入用的分頁功成身退
await use(context); // 交付的是 context,不是 page
await context.close();
}, { scope: 'worker' }],
sharedPage: async ({ sharedContext }, use) => {
// 每個測試開一個新分頁,因為在同一個 (worker-scoped) context 底下,所以一開就是已登入狀態
const page = await sharedContext.newPage();
await use(page);
await page.close();
},
});
幾個值得注意的細節:
sharedContext 是 worker scope、sharedPage 是 test scope。worker-scoped fixture 只能依賴其他 worker-scoped fixture,但 test-scoped 可以依賴 worker-scoped 的 fixtures。use() 交付的是 context 不是 page。這是 C 跟 B 最關鍵的差異。use 的設定,所以像是 baseURL、viewport 這些,都必須在 newContext() 時自己再給一次。這點後面還會再說明。把前面那三支觀察測試改成用 sharedPage。因為每個測試拿到的是剛開的新分頁,會是空白頁,所以每支測試都要自己 goto 回首頁:
// tests/shared-context-demo.spec.ts
import { test, expect } from '../fixtures/fixtures';
test('觀察 1:可以看到後台首頁標題', async ({ sharedPage }) => {
await sharedPage.goto('/');
await expect(
sharedPage.getByRole('heading', {
name: 'Welcome to the react-admin e-commerce demo',
})
).toBeVisible();
});
test('觀察 2:左側選單有 Posters', async ({ sharedPage }) => {
await sharedPage.goto('/');
await expect(
sharedPage.getByRole('menuitem', { name: 'Posters' })
).toBeVisible();
});
test('觀察 3:左側選單有 Categories', async ({ sharedPage }) => {
await sharedPage.goto('/');
await expect(
sharedPage.getByRole('menuitem', { name: 'Categories' })
).toBeVisible();
});
跑起來看看:
npx playwright test shared-context-demo --project=chromium --workers=1
Running 3 tests using 1 worker
[chromium] › tests/shared-context-demo.spec.ts:3:5 › 觀察 1:可以看到後台首頁標題
>>> 執行登入流程
3 passed (10.9s)
>>> 執行登入流程 只印一次,而且觀察 2、觀察 3 的測試開的新分頁不需要重新登入,直接驗證了登入狀態確實存在 context,不在 page。
現在我們在觀察 1 的結尾多做一個動作(在商品列表打一段搜尋關鍵字),並在觀察 2 的開頭斷言搜尋框是空的:
// tests/shared-context-demo.spec.ts
import { test, expect } from '../fixtures/fixtures';
import { ProductsPage } from '../pages/ProductsPage';
test('觀察 1:在商品列表打一段搜尋關鍵字', async ({ sharedPage }) => {
await sharedPage.goto('/');
const productsPage = new ProductsPage(sharedPage);
await productsPage.goto();
// 觀察 1 結尾:輸入關鍵字
await productsPage.search('poster');
});
test('觀察 2:確認搜尋框是否為空', async ({ sharedPage }) => {
await sharedPage.goto('/');
const productsPage = new ProductsPage(sharedPage);
await productsPage.goto();
// 觀察 2 開頭:斷言搜尋框是空的
await expect(productsPage.searchInput).toHaveValue('');
});
我們分別使用組合 B 和組合 C 來做驗證:
workerLoggedInPage(組合 B):失敗,因為觀察 2 拿到的是觀察 1 用過的那個分頁,搜尋條件還留在上面。sharedPage(組合 C):通過,因為觀察 2 的分頁是全新開的。今天我們從 serial 開始,一路探討了測試共用的不同層級:
serial 是有相依性時的妥協方案:讓測試在同一個 Worker 順序執行,前面失敗後面直接 skip、retry 整組重跑。不過共用 Context 之後,測試失敗時的影片跟 Trace 紀錄還能順利留下來嗎?這個問題牽涉到 Playwright 除錯工具綁定的層級,明天我們就來把 screenshot、video 和 trace 搞清楚吧!