iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
自我挑戰組

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

Day 14 - 共用的層級:serial mode 與 context / page 的選擇

  • 分享至 

  • xImage
  •  

上一篇我們介紹了 Worker 的運作原理:每個 Worker 都是獨立的 Node.js 程序,彼此的記憶體完全不共享;而 fullyParallel: true 會把分配的基準單位從「檔案」縮小到「單一測試」,所以同一個檔案裡的測試會被拆散到不同 Worker,執行順序也不保證。

但如果我不希望它們被拆散,該怎麼做呢?另外同一個 Worker 裡的測試,除了可以「排隊執行」之外,是否也能共用context跟page呢?

利用serial將幾支測試綁在一起執行

實務上偶爾會遇到跟平行化相反的情況,例如某幾支測試就是有先後順序,像是必須先建立一筆資料進db,後面才能編輯它、刪除它。這時候可以用 test.describe.configure() 把它們綁成一組:

test.describe('商品的建立與刪除', () => {
  test.describe.configure({ mode: 'serial' });

  test('建立一筆新商品', async ({ page }) => { /* ... */ });
  test('編輯剛剛建立的商品', async ({ page }) => { /* ... */ });
  test('刪除剛剛建立的商品', async ({ page }) => { /* ... */ });
});

mode: 'serial' 做了三件事:

  1. 這一組測試會被分配到同一個 Worker,不會被 fullyParallel 拆散。
  2. 依照撰寫順序執行。
  3. 前面失敗,後面直接 skip。

這邊比較特別的一點是,在 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 是「分頁」。所以:

  • 登入狀態(cookie、localStorage)存在 Context
  • Page 只是 Context 底下的一個分頁,同一個 Context 開出來的每個新分頁,都自動帶著那份登入狀態

重新認識 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:只共用 Page、不共用 Context — 這個組合不存在 —

D 選項在直覺上好像應該存在,但Playwright的設計上,Page 必定隸屬於某一個 Context,所以只要共用了 Page,就一定共用了Context,也就是上面的 B 這種情況,所以不存在 D 的情況。

B 跟 C 的差別在於,B把共用延伸到Page,也就是把Page狀態也共用了,所以測試之間會看到彼此的畫面狀態,C只到Context層級,每支測試拿到的是全新的分頁。

動手做:只共用 Context,不共用 Page

我們拆成兩個 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();
  },
});

幾個值得注意的細節:

  1. sharedContext 是 worker scope、sharedPage 是 test scope。worker-scoped fixture 只能依賴其他 worker-scoped fixture,但 test-scoped 可以依賴 worker-scoped 的 fixtures。
  2. 登入用的分頁用完就關掉。登入的目的是把狀態寫進 context,寫完之後那個分頁就沒用了,留著只會佔資源。
  3. use() 交付的是 context 不是 page。這是 C 跟 B 最關鍵的差異。
  4. 手動建立的 context 不會繼承 config 裡 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 開始,一路探討了測試共用的不同層級:

  1. serial 是有相依性時的妥協方案:讓測試在同一個 Worker 順序執行,前面失敗後面直接 skip、retry 整組重跑。
  2. 登入狀態存在 Context,不在 Page:Page 只是 Context 底下的分頁,只要共用 Context,開啟的新分頁就自動帶有登入狀態。
  3. 只共用 Context(組合 C)兼顧效能與隔離:每個 Worker 只要登入一次,又能讓每支測試拿到乾淨的新分頁,解決了共用 Page 畫面殘留的問題。

不過共用 Context 之後,測試失敗時的影片跟 Trace 紀錄還能順利留下來嗎?這個問題牽涉到 Playwright 除錯工具綁定的層級,明天我們就來把 screenshot、video 和 trace 搞清楚吧!


上一篇
Day 13 - Worker 機制解析:平行執行原理
下一篇
Day 15 - 除錯產出物:screenshot、video 與 trace
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言