還記得 Day 6 介紹 Page Object Model 時,我們把登入步驟都搬到 LoginPage 類別,雖然登入邏輯集中管理了,但每寫一支新測試,還是得把這三行複製貼上一次:
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
想像一下,當我們的測試擴充到幾十、甚至上百支的時候,這三行程式碼會不斷重複出現。此外每一次跑測試,瀏覽器都得乖乖地從頭走一遍完整的登入流程,這其實非常耗時。
要解決這兩個問題,我們需要用到 Playwright 裡強大的核心功能:Fixture。今天我們會先認識 Fixture,並用它來解決程式碼不斷重複的問題,把登入邏輯優雅地封裝起來;至於如何解決「每次都要重新登入」的耗時問題,我們會在下一篇文章來徹底解決!
beforeEach 還不夠?在 Day 6 我們有提過,beforeEach 的作用範圍僅限於「當下這個測試檔案」。如果你把它寫在 beforeEach,的確可以讓同一個檔案裡的測試少寫幾行,但只要換了一個新的測試檔案,你還是得重新 new 一個 LoginPage,再重新呼叫一次登入。這種方式治標不治本,所以我們需要一個可以真正解決這種「跨檔案重複」問題的方案。
在正式動手寫 Fixture 之前,我們先來看看每天都在寫的這行程式:
test('測試標題', async ({ page }) => {
// ...
});
這邊這個 page 到底是從哪來的?我們從來沒有自己動手 new 一個分頁,也沒有去 import 什麼相關的套件,但只要在參數裡寫上 { page },測試執行的時候就會自動拿到一個乾淨、全新的瀏覽器分頁。
這個自動送到你手上的東西,其實就是 Fixture。
Fixture 的運作機制,其實是一種依賴宣告,可以拆成四個步驟:
async ({ page }) => {...},Playwright 就會去找一個叫做 page 的 fixture 定義。page 為例,它在背後大概做的事情是開一個新的 browser context → 在裡面開一個新分頁。這整段「開分頁、關分頁」的邏輯,我們平常完全不需要自己寫,因為 Playwright 已經把它包成 page 這個 fixture 了。
同理,大家熟悉的 browser、context 甚至發 API 用的 request,全都是 Playwright 內建好的 Fixture。它們各自包裝了不同的準備與收尾邏輯:
| 內建 Fixture | 實際上幫你準備了什麼? |
|---|---|
page |
一個乾淨的獨立分頁(這背後其實會先開好 context) |
context |
獨立的瀏覽器環境,確保 cookie 跟 storage 都不會跟別人混在一起 |
browser |
瀏覽器本人,這是最底層的實例 |
request |
專門用來發送 HTTP 請求的工具,不需要開畫面 |
既然 Playwright 可以自己包裝這些 Fixture,那我們是不是也可以自己客製化一個?沒錯,我們接下來就要自己打造一個叫做 loggedInPage 的 Fixture,讓它幫我們把「打開頁面、輸入帳密登入」的麻煩事一次做完!
loggedInPage Fixture要擴充自訂的 Fixture,我們會用到 test.extend() 這個方法:
// fixtures/fixtures.ts
import { test as base, type Page } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
// 在原本的 test 基礎上,擴充一個我們自己取名的 loggedInPage
export const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
// 步驟 1:準備階段。呼叫內建的 page,讓它幫忙走完登入流程
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
// 步驟 2:交付階段。用 use() 把這個「已經登入好的 page」交接給測試
await use(page);
// 步驟 3:收尾階段。這裡的程式碼會在測試執行「完畢」後才觸發
// 以這個 demo 專案為例,它是把狀態寫在 localStorage 裡,
// 我們可以順手清乾淨,維持環境的整潔。
await page.evaluate(() => localStorage.clear());
},
});
export { expect } from '@playwright/test';
對照前面拆解的四個步驟,這段程式碼可以這樣理解:
page 來做事(Fixture 可以依賴其他 Fixture),用它把登入的流程跑完。use(page) 是一個非常關鍵的斷點。當程式走到這裡,Fixture 的工作會暫停,並把準備好的 page 丟給你的測試去跑。use(page) 的下一行繼續執行,這個時機點適合放「清理這次準備動作留下的痕跡」,常見的例子包括:登出、把測試過程中建立的資料刪掉、關閉額外開啟的分頁或連線等。雖然 Playwright 預設本來就會給我們乾淨的環境,但養成流程結束就清理資源的好習慣,之後在處理共用資源時才不會出問題。做完這個設定以後,寫測試時只要改從我們自製的 fixtures.ts 匯入 test,然後宣告你要 { loggedInPage }就ok了!
// dashboard.spec.ts
import { test, expect } from '../fixtures/fixtures';
test('登入後可以看到後台首頁', async ({ loggedInPage }) => {
await expect(
loggedInPage.getByRole('heading', {
name: 'Welcome to the react-admin e-commerce demo',
})
).toBeVisible();
});
我們來回顧一下這一段進化。
這是改寫前(Day 6 的寫法):
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('登入後可以看到後台首頁', async ({ page }) => {
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();
});
這是改寫後(使用 Fixture):
import { test, expect } from './fixtures';
test('登入後可以看到後台首頁', async ({ loggedInPage }) => {
await expect(
loggedInPage.getByRole('heading', {
name: 'Welcome to the react-admin e-commerce demo',
})
).toBeVisible();
});
你會發現那三行登入的程式碼消失,整個測試的程式碼會更聚焦在「要驗證的商業邏輯」上,而前置作業全都交給幕後的 Fixture 去打理。這就是 Fixture 帶來的好處。
loggedInPage 搭配其他 Page Object 一起用看到這裡,你可能會想:「如果我今天要測的不是首頁,而是 Day 7 寫的商品新增流程呢?我可不可以直接在參數裡寫 { productsPage },讓 Playwright 也自動幫我準備好?」
這個思考方向是正確的,但目前還無法這樣做,因為loggedInPage 是我們建立的 Fixture,但 ProductsPage 跟 ProductCreatePage 還只是 TypeScript 類別且沒有註冊為fixture,所以Playwright 並不認得它們,自然也就不可能幫我們自動建立、自動注入。
目前用到這些 Page Object 時,你還是得手動 new 它們,但要注意的是你該傳哪個page給這些Page Object,因為我們接下來要操作的商品列表、商品新增頁,都是建立在「已經登入過」的畫面之上,所以傳進 ProductsPage、ProductCreatePage 的constructor的必須換成 loggedInPage
我們用 Day 7 的 CRUD 測試來改寫,看看差異:
// crud-product.spec.ts
import { test, expect } from './fixtures';
import { ProductsPage } from '../pages/ProductsPage';
import { ProductCreatePage } from '../pages/ProductCreatePage';
test('成功建立一筆新商品並顯示在列表中', async ({ loggedInPage }) => {
// 注意這裡傳進去的是已經跑完登入的 loggedInPage
const productsPage = new ProductsPage(loggedInPage);
const productCreatePage = new ProductCreatePage(loggedInPage);
const testProductName = `Awesome Poster ${Date.now()}`;
// 因為已經透過loggedInPage登入過了,所以不需要再登入,直接從操作商品列表開始
await productsPage.goto();
await productsPage.clickCreate();
await productCreatePage.fillProductForm('https://example.com/poster.jpg', {
reference: testProductName,
width: '10',
height: '20',
price: '29.99',
stock: '10',
category: 'animals',
});
await productCreatePage.save();
await productsPage.goto();
await productsPage.search(testProductName);
await productsPage.expectProductVisible(testProductName);
});
你會發現新的做法只省去了手動登入的步驟,剩下的程式完全沒變,所以可以得出一個結論:fixture 只把「登入」這一個特定的準備動作自動化了,不會連帶把專案裡所有的 Page Object 都變成自動注入
那如果想要更進一步,讓 productsPage、productCreatePage 也能像 loggedInPage 一樣直接宣告在參數裡使用呢?這會牽涉到 Fixture 互相依賴的進階寫法,我們留到明天 Day 12 再來研究!
今天我們徹底拆解了 Fixture 的底層機制:宣告 → 準備 → 交付 → 收尾,並理解到 Playwright 內建的 page、context、browser、request 其實都是照著這套機制運作的 fixture,此外還用 test.extend() 打造了第一個專屬的 loggedInPage Fixture,成功把測試開頭那三行的登入邏輯簡化掉。
下一篇,我們會來探討 Fixture 的 scope 設定,看看怎麼透過 Fixture 真正實現「登入一次,大家共用」的終極目標!