iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
自我挑戰組

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

Day 11 - Fixture 基礎:為什麼我們需要它?

  • 分享至 

  • xImage
  •  

還記得 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 是什麼?

在正式動手寫 Fixture 之前,我們先來看看每天都在寫的這行程式:

test('測試標題', async ({ page }) => {
  // ...
});

這邊這個 page 到底是從哪來的?我們從來沒有自己動手 new 一個分頁,也沒有去 import 什麼相關的套件,但只要在參數裡寫上 { page },測試執行的時候就會自動拿到一個乾淨、全新的瀏覽器分頁。

這個自動送到你手上的東西,其實就是 Fixture。

Fixture 的運作機制,其實是一種依賴宣告,可以拆成四個步驟:

  1. 宣告:測試函式的參數名稱,就是你要跟 Playwright「要」的東西的名字。你寫 async ({ page }) => {...},Playwright 就會去找一個叫做 page 的 fixture 定義。
  2. 準備:每個 fixture 定義,本質上是一段「準備資源」的邏輯。以內建的 page 為例,它在背後大概做的事情是開一個新的 browser context → 在裡面開一個新分頁。
  3. 交付:準備好之後,把這個 page 物件交給測試使用,測試才真正開始執行裡面的程式碼。
  4. 收尾:等測試跑完之後,自動關閉這個 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';

對照前面拆解的四個步驟,這段程式碼可以這樣理解:

  • 我們跟 Playwright 借用了原本的 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 真正實現「登入一次,大家共用」的終極目標!


上一篇
Day 10 - Assertions 與 expect API:這些斷言方法你都用對了嗎?
下一篇
Day 12 - Fixture 進階:相依注入與 Scope
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言