iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

Day 11 我們用 test.extend() 打造了第一個自訂 Fixture loggedInPage,成功讓登入流程自動化。但改寫完的 CRUD 測試,開頭還是有這兩行:

const productsPage = new ProductsPage(loggedInPage);
const productCreatePage = new ProductCreatePage(loggedInPage);

既然 loggedInPage 可以直接宣告在參數裡拿到,為什麼 productsPage 不行呢?

另外loggedInPage 只是把登入操作封裝起來,並沒有讓它少做。現在每一支宣告 loggedInPage 的測試,背後都還是老老實實地開瀏覽器、填帳密、按登入。當測試一多,這些重複的登入時間加起來就很可觀。

今天就把這兩個問題一次解決!第一個問題可以透過 Fixture 相依注入 來解決,第二個問題可以透過 Fixture 的 scope 設定 來解決。

把 Page Object 也註冊成 Fixture

在 Day 11 我們提過 Fixture 可以依賴其他 Fixture。我們的 loggedInPage 就是靠依賴內建的 page 才能運作。我們可不可以讓 productsPage 去依賴 loggedInPage 呢?可以,我們可以把 fixtures.ts 擴充成這樣:

// fixtures.ts
import { test as base, type Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductsPage } from '../pages/ProductsPage';
import { ProductCreatePage } from '../pages/ProductCreatePage';

// 這次要註冊的 fixture 不只一個,先把型別集中宣告成一個 type
type TestFixtures = {
  loggedInPage: Page;
  productsPage: ProductsPage;
  productCreatePage: ProductCreatePage;
};

export const test = base.extend<TestFixtures>({
  // Day 11 做的 fixture,內容完全沒變
  loggedInPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('demo', 'demo');
    await use(page);
    await page.evaluate(() => localStorage.clear());
  },

  // 新增:依賴 loggedInPage,準備一個「建好的 ProductsPage 實例」
  productsPage: async ({ loggedInPage }, use) => {
    await use(new ProductsPage(loggedInPage));
  },

  // 同理
  productCreatePage: async ({ loggedInPage }, use) => {
    await use(new ProductCreatePage(loggedInPage));
  },
});

export { expect } from '@playwright/test';

這兩個新 fixture 只要 new 一個 Page Object,然後用 use() 交出去,連收尾都不需要(loggedInPage 會處理登入狀態清理)。

這是改寫前的測試(Day 11 的版本):

import { test, expect } from '../fixtures/fixtures';
import { ProductsPage } from '../pages/ProductsPage';
import { ProductCreatePage } from '../pages/ProductCreatePage';

test('成功建立一筆新商品並顯示在列表中', async ({ loggedInPage }) => {
  const productsPage = new ProductsPage(loggedInPage);
  const productCreatePage = new ProductCreatePage(loggedInPage);

  const testProductName = `Awesome Poster ${Date.now()}`;

  await productsPage.goto();
  await productsPage.clickCreate();
  // ...後略
});

這是改寫後:

import { test, expect } from '../fixtures/fixtures';

test('成功建立一筆新商品並顯示在列表中', async ({ productsPage, productCreatePage }) => {
  const testProductName = `Awesome Poster ${Date.now()}`;

  await productsPage.goto();
  await productsPage.clickCreate();
  // ...後略
});

Page Object 的 import 跟 loggedInPage 都不用宣告,測試檔案裡只剩下「操作」跟「斷言」,前置作業全部由 fixture 依賴鏈在幕後處理。Playwright 看到你要 productsPage,發現它依賴 loggedInPage,而 loggedInPage 又依賴 page,於是沿著這條鏈一路往上準備,最後把成品交到你手上。

這裡有三件事必須釐清:

1. 同時宣告兩個 fixture,登入只會跑一次嗎?

是的,只跑一次。productsPage 跟 productCreatePage 都依賴 loggedInPage,但在同一支測試裡,每個 fixture 只會被建立一次,所以兩個 Page Object 拿到的是同一個已登入的 page 實例,因此不需要擔心註冊越多 fixture、登入就跑越多次。

2. fixture 的宣告順序重要嗎?

不重要。你把 productsPage 寫在 loggedInPage 前面也完全沒問題,因為 Playwright 不是照著物件裡的順序執行,而是照依賴關係圖決定誰先誰後。誰被依賴,誰就先準備。

3. 註冊一堆 fixture,會拖慢所有測試嗎?

不會。Fixture 是 lazy(惰性) 的:只有測試參數裡真的宣告用到的 fixture 才會被建立。一支只宣告 { page } 的測試,完全不會觸發任何登入流程。也因為註冊沒有什麼成本,所以是否要將每個 Page Object 都註冊成 fixture會是一個考量點。

Scope:Fixture 的生命週期

接下來看第二個問題:為什麼每支測試還是各自登入一次?

這涉及到之前沒有介紹過的設定:fixture 的 scope。每個 fixture 都有一個作用範圍,預設值是 test,意思是「每支測試都完整走一遍fixture的準備 → 交付 → 收尾」。我們的 loggedInPage 沒特別設定,所以三支測試就是三次登入,這是預設行為,不是 bug。

為什麼 Playwright 預設要這麼做?因為這是它測試隔離概念的一部分:每支測試拿到的都是全新資源,前一支測試不管做了什麼,都不會影響到下一支,確保測試環境乾淨。這一點對於避免 cross-contamination、確保測試結果穩定、方便除錯都非常重要。

但如果測試數量非常多,每次都要重新登入,要跑完所有測試就會變得很慢。這時就可以考慮將 scope 設定成 worker,讓多支測試共用同一個 worker 的生命週期,減少重複的準備工作。這裡得先簡單認識一下 worker:Playwright 執行測試時,並不是在同一個程序裡從頭跑到尾,而是開好幾個獨立的「worker 程序」來分工,每個 worker 會依序執行分配給它的多支測試。

scope 設成 worker 的 fixture,就是以 worker 為單位活著:worker 執行第一支需要它的測試時準備一次,之後這個 worker 裡的所有測試共用同一個fixture,直到 worker 收工才收尾。兩種 scope 對照如下:

scope: 'test'(預設) scope: 'worker'
準備幾次 每支測試一次 每個 worker 一次
誰共用 沒共用,各自獨立 同 worker 內的所有測試
收尾時機 每支測試結束後 worker 結束時
隔離性 完整 犧牲(前面測試的狀態會留給後面)

動手寫 worker-scoped 登入

現在來實作「登入一次,整個 worker 共用」。直覺上,應該只要把 Day 11 的 loggedInPage 加上 scope 設定就好了,對吧?

// 直覺版:把原本的 loggedInPage 直接改成 worker scope
loggedInPage: [async ({ page }, use) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('demo', 'demo');
  await use(page);
  await page.evaluate(() => localStorage.clear());
}, { scope: 'worker' }],

實際跑了後,會直接報錯:

worker fixture "loggedInPage" cannot depend on a test fixture "page" defined in <builtin>.

錯誤訊息說得很清楚:worker-scoped fixture 不能依賴 test-scoped fixture。而我們依賴的 page,正是 test-scoped 的(這也是為什麼每支測試都能拿到全新分頁)。

為什麼有這條規則呢? worker-scoped fixture 要活過很多支測試,而 page 在每支測試結束就被回收了。如果允許依賴,等第二支測試開始時,worker fixture 手上抓著的那個 page 早就已經被銷毀了。

所以正確做法是:依賴同為 worker-scoped 的內建 fixture browser(瀏覽器本身活得跟 worker 一樣久),然後自己動手開 context、開 page。

// fixtures/fixtures.ts(節錄新增部分)

// worker-scoped fixture 的型別要「另外」宣告一個 type
type WorkerFixtures = {
  workerLoggedInPage: Page;
};

// 注意 extend 的第二個泛型參數!
export const test = base.extend<TestFixtures, WorkerFixtures>({
  // ...前面的 loggedInPage、productsPage、productCreatePage 維持原樣...

  workerLoggedInPage: [async ({ browser }, use) => {
    console.log('>>> 執行登入流程');

    // 手動開 context 跟 page(原本這是內建 page fixture 幫我們做的事)
    const context = await browser.newContext({ baseURL: 'http://localhost:8000', video: 'retain-on-failure', trace: 'on-first-retry', });
    const page = await context.newPage();

    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('demo', 'demo');

    // 交付:這個 worker 裡的每支測試,拿到的都是「同一個」page
    await use(page);

    // 收尾:整個 worker 的測試都跑完才會執行到這裡
    await context.close();
  }, { scope: 'worker' }],
});

這段程式碼有三個細節要注意:

細節一:型別要放在第二個泛型參數。 test.extend<TestFixtures, WorkerFixtures> 的第一個泛型是 test-scoped fixture、第二個才是 worker-scoped。如果把 workerLoggedInPage 誤放進第一個型別參數,TypeScript 會跳出型別錯誤。這是很多人第一次寫 worker fixture 常見的寫法錯誤。

細節二:手動 newContext() 不會繼承 config 設定。 內建的 page fixture 會自動套用 playwright.config.ts 裡 use 區塊的設定(baseURL、viewport 等),但我們自己呼叫 browser.newContext() 拿到的是一張白紙,什麼都不會帶。LoginPage.goto() 裡那句 page.goto('/') 會因為沒有 baseURL 而失敗,所以我們需要手動把 baseURL 傳進去。我們之前設定的測試過程的video錄製跟trace設定,如果在這邊也需要的話,也必須手動傳入context設定中,否則將無法生效。

細節三:收尾責任回到自己身上。 內建的 page 把 context 建立跟銷毀的邏輯都已經被包在 fixture 裡;但自定義的 worker-scoped fixture 的 context 是我們自己開的,就得自己在 use() 之後關掉,不然瀏覽器資源會一路佔用到程序結束。

驗證新寫法只會登入一次

現在我們用剛剛埋的那行 console.log 來驗證登入只會執行一次,開一個新的測試檔,放三支小測試:

// tests/worker-scope-demo.spec.ts
import { test, expect } from '../fixtures/fixtures';

test('觀察 1:可以看到後台首頁標題', async ({ workerLoggedInPage }) => {
  await expect(
    workerLoggedInPage.getByRole('heading', {
      name: 'Welcome to the react-admin e-commerce demo',
    })
  ).toBeVisible();
});

test('觀察 2:左側選單有 Posters', async ({ workerLoggedInPage }) => {
  await expect(
    workerLoggedInPage.getByRole('menuitem', { name: 'Posters' })
  ).toBeVisible();
});

test('觀察 3:左側選單有 Customers', async ({ workerLoggedInPage }) => {
  await expect(
    workerLoggedInPage.getByRole('menuitem', { name: 'Customers' })
  ).toBeVisible();
});

先限制只用一個 worker 執行,方便看清楚「共用」的效果:

npx playwright test worker-scope-demo --project=chromium --workers=1

三支測試都通過,但 >>> 執行登入流程 只印出一次。對照組也很簡單:把這三支測試的參數暫時改回 loggedInPage(test-scoped)再跑一次,log 會乖乖印三次。同樣三支測試,登入成本從三次降到一次,測試越多差距越大。

這次我們把 --workers=1 拿掉再跑一次。你會發現 log 印出的次數變成「實際用到的 worker 數」,因為我們的專案在playwright.config.ts 設定了 fullyParallel: true,表示這三隻測試可以平行執行,所以他們可能被分配到不同 worker執行,而每個 worker 都會為自己準備一份 workerLoggedInPage。至於 Playwright 到底開了幾個 worker、測試怎麼分配,這部分之後會再詳細說明。

天下沒有白吃的午餐:共用的代價

看到這邊你可能會想,既然 worker scope 這麼省時間,為什麼 Playwright 不乾脆把它當預設呢?

主要是因為共用的話就無法避免資源汙染的問題。三支觀察測試共用同一個 page,代表如果觀察 1 的測試不小心打開了一個 modal 沒關、或是把列表搜尋條件留著沒清,觀察 2 面對的就是那個已經被修改過的畫面。測試之間開始互相依賴干擾,測試失敗的原因很有可能根本不是來自於他自己本身的問題,而是前面測試過程留下的副作用。

所以在寫 loggedInPage 時,我們特別強調要養成在 use() 之後清理資源的習慣。在 test scope 的世界裡,就算不清理,Playwright 每次給你的都是新環境,錯了也不會影響到其他測試;但進到共用資源的世界,收尾清理的重要性就會顯著提高。

今日總結

今天解決了 Day 11 的兩個小問題:用 fixture 相依注入讓 Page Object 直接宣告在測試參數裡,測試本體只剩操作跟斷言;用 scope: 'worker' 做到登入一次、整個 worker 共用,但同時也碰到一個限制:worker-scoped fixture 只能依賴 worker-scoped fixture。

不過 worker 到底是什麼、怎麼分配測試呢?明天我們詳細說明 Playwright 平行執行的原理後,你就不會有疑問了。


上一篇
Day 11 - Fixture 基礎:為什麼我們需要它?
下一篇
Day 13 - Worker 機制解析:平行執行原理
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言