Day 11 我們用 test.extend() 打造了第一個自訂 Fixture loggedInPage,成功讓登入流程自動化。但改寫完的 CRUD 測試,開頭還是有這兩行:
const productsPage = new ProductsPage(loggedInPage);
const productCreatePage = new ProductCreatePage(loggedInPage);
既然 loggedInPage 可以直接宣告在參數裡拿到,為什麼 productsPage 不行呢?
另外loggedInPage 只是把登入操作封裝起來,並沒有讓它少做。現在每一支宣告 loggedInPage 的測試,背後都還是老老實實地開瀏覽器、填帳密、按登入。當測試一多,這些重複的登入時間加起來就很可觀。
今天就把這兩個問題一次解決!第一個問題可以透過 Fixture 相依注入 來解決,第二個問題可以透過 Fixture 的 scope 設定 來解決。
在 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會是一個考量點。
接下來看第二個問題:為什麼每支測試還是各自登入一次?
這涉及到之前沒有介紹過的設定: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 共用」。直覺上,應該只要把 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 平行執行的原理後,你就不會有疑問了。