
Architecture Journal
E2E 測試:AI 時代的自動化測試與驗收(八)如何設計資料隔離與可維護的 Playwright 架構?
上一篇談的是「哪些使用者旅程值得用 E2E 保護」。這一篇往前走一步,討論另一個更容易在實務上卡住的問題:
E2E 測試要怎麼準備資料、保持測試獨立,並且讓測試程式碼可以長期維護?
當測試只有幾條時,直接在測試裡登入、建立資料、操作畫面,看起來最快。等到測試數量增加,問題就會一起出現:測試互相依賴、資料清不乾淨、平行執行不穩定,最後每個人都開始複製一份自己的登入與資料建立流程。
Playwright 提供了 Page、BrowserContext、APIRequestContext 與 fixtures,但它不會替我們決定應用程式資料如何隔離。真正重要的設計,是把測試情境、測試資料、UI 操作與生命週期管理分開。
這篇的核心原則是:
測試要獨立,靠的是資料邊界;程式碼要重用,靠的是清楚分工。
先看這些元件怎麼連在一起
這篇不是把 test spec、fixture、POM、API/service 與 data 分別介紹一遍。它們其實是在描述同一件事的不同顆粒度:一個測試案例如何從「要驗證什麼」,一路落到「用什麼資料把前置狀態準備出來」。
如果按照從大到小的顆粒度列出,我會依序看:
- test spec
- fixture
- POM
- API/service
- data
但這不是一條單一路徑。比較準確的關係是:test spec 使用 fixture;fixture 在同一層組合 POM、API/service 與 data。POM 和 API/service 是同層能力,data 是兩者共同操作的測試狀態。
| 層級 | 它回答的問題 | 主要責任 |
|---|---|---|
| test spec | 使用者情境與驗收結果是什麼? | 描述測試意圖、操作流程與驗證結果 |
| fixture | 這個測試需要哪些資源?活多久? | 組合依賴,管理 setup 與 teardown |
| POM | UI 要怎麼操作? | 封裝頁面定位器與畫面操作 |
| API/service | 前置狀態要怎麼建立或清理? | 呼叫 API、test service 或其他資料入口 |
| data | 測試實際使用哪些狀態與識別值? | 定義資料內容、所有權與唯一性 |
這個列法是設計與閱讀上的顆粒度,不是嚴格的執行呼叫順序。執行時,test spec 通常先取得 fixture 提供的資源;fixture 可能透過 API/service 建立 data,也可能把 POM 組合進來;spec 再使用 POM 操作 UI,最後驗證結果。
用「取消已付款訂單」來看,關係會比較清楚:spec 描述使用者可以取消訂單,fixture 準備登入身分與測試訂單,POM 負責開啟訂單頁和按下取消,API/service 建立已付款訂單並在結束後清理,data 則決定訂單的唯一 ID、付款狀態與相關 seed。這些不是五個獨立的工具,而是同一個案例從意圖到實作的五個切面。
後面的內容會沿著這個關係展開:先看 spec 如何提出驗證需求,再看 fixture 如何組合資源,接著拆開 POM 與 API/service 的同層責任,最後集中處理 data 的隔離、平行執行與清理。
Test spec 是這條關係的起點
最上層的 test spec 應該保留使用者情境與業務驗證。它可以使用其他層提供的能力,但不需要知道資料是透過哪個 API 建立,也不需要知道按鈕使用哪個 CSS selector。
test('customer can cancel a paid order', async ({ orderData, orderPage }) => {
await orderPage.open(orderData.id);
await orderPage.cancel();
await expect(orderPage.status).toHaveText('已取消');
});
這段測試的價值,在於讀者一眼就能看出它驗證什麼。至於登入、建立已付款訂單、準備頁面物件與清理資料,會由下面幾層接手。這也是整篇文章要守住的方向:越靠近 spec,越接近測試意圖;越往下,越接近可重用的實作細節。
Fixture 是組合與生命週期的邊界
test spec 不應該自己把 POM、API client、測試資料與清理流程一個個組合起來。fixture 位在 spec 與這些測試資源之間,負責把依賴接起來,並決定它們什麼時候建立、什麼時候清理。
先看同一個「取消已付款訂單」案例如何被組合:
import { test as base } from '@playwright/test';
import { OrderDataClient } from '../services/OrderDataClient';
import { OrderPage } from '../pages/OrderPage';
type Fixtures = {
orderData: { id: string };
orders: OrderDataClient;
orderPage: OrderPage;
};
export const test = base.extend<Fixtures>({
orders: async ({ request }, use) => {
await use(new OrderDataClient(request));
},
orderData: async ({ orders }, use) => {
const order = await orders.createPaidOrder({
// seed-user、seed-product 必須是不可變的參考資料;訂單本身每次建立新 ID。
// 如果這些資源會被修改,應改由 data factory 產生唯一資源。
userId: 'seed-user',
productId: 'seed-product',
});
await use(order);
await orders.deleteOrder(order.id);
},
orderPage: async ({ page }, use) => {
await use(new OrderPage(page));
},
});
export { expect } from '@playwright/test';
這裡先看關係,不需要把每個 fixture 寫成完整實作:fixture 組合 OrderPage 與 OrderDataClient,資料 fixture 負責建立和清理訂單,spec 則只取得自己要用的資源。POM 與 API/service 是 fixture 下的同層能力,兩者都服務同一個 spec,但不應互相取代。
Fixture scope 要和資料生命週期一致
Playwright fixture 最容易被誤用的地方,是 scope 選錯。
可以用一個簡單的判斷方式:
| 資源 | 常見生命週期 | 原因 |
|---|---|---|
Page、BrowserContext | test fixture | 每個測試都需要獨立瀏覽器狀態 |
| 測試訂單 | test fixture | 測試會修改它的狀態 |
| 測試帳號 | worker 或 test fixture | 取決於帳號是否會被修改 |
| 靜態 seed | setup project 或 worker fixture | 建立一次即可共用 |
| 開發伺服器或環境 | webServer 或 worker-level resource | 不需要每個測試重啟 |
這裡的 test 與 worker 是 fixture 的生命週期;project 則通常代表測試設定或 setup project,不是一般 fixture scope。
如果把訂單放在 worker scope,第一個測試取消了它,第二個測試就可能收到已取消的訂單。這類錯誤在單獨執行時不一定出現,平行執行或測試順序改變後才會暴露。
認證狀態也要特別小心。storageState 可以減少每個測試重複登入的成本,但它只代表瀏覽器身分與 session,不代表業務資料已經可以安全共用。若測試不會修改共享的伺服器狀態,可以共用一個帳號;若測試會修改伺服器狀態,則應該讓每個 worker 使用不同帳號。Playwright 官方的 authentication 指南也把認證狀態與測試隔離策略分開處理。[3]
同樣地,若 test-support API 需要登入,必須在 request fixture 或自訂 client 中明確設定認證,不要假設 page 的 session 會自動套用到另一個 API request context。
POM 應該封裝什麼
fixture 提供了 orderPage,但頁面物件本身只需要知道畫面怎麼操作,不需要知道訂單怎麼建立。POM 是 fixture 組合的其中一個同層能力,負責把頁面定位器與 UI 操作集中管理。
Page Object Model 的目的,是把頁面定位器與頁面操作集中管理,讓測試不需要到處知道 CSS selector、按鈕文字或頁面結構。[2]
一個 OrderPage 可以提供:
- 開啟某筆訂單。
- 取消訂單。
- 讀取訂單狀態。
- 等待頁面準備完成。
但它不應該知道:
- 測試要建立哪一種使用者。
- 這筆訂單是否應該由 API 或資料庫建立。
- 哪些測試情境需要先付款。
- 整條跨頁面、跨服務的業務流程如何組合。
一個簡化的 POM 可以長這樣:
import type { Locator, Page } from '@playwright/test';
export class OrderPage {
readonly status: Locator;
constructor(private readonly page: Page) {
this.status = page.getByTestId('order-status');
}
async open(orderId: string) {
await this.page.goto(`/orders/${orderId}`);
}
async cancel() {
await this.page.getByRole('button', { name: '取消訂單' }).click();
}
}
這個物件負責「怎麼操作訂單頁面」,但「取消後什麼結果才算成功」仍然由測試決定。
Test id 是穩定契約,但不是所有元素的預設答案
在頁面上加 data-testid、data-test 或其他專用屬性,可以建立穩定而明確的測試契約。Playwright 將 test id 列為內建定位方式,但同時建議優先使用貼近使用者操作的定位器;Testing Library 也把 role、label、text 等語意查找方式排在 test id 前面。[5] [6] Playwright 預設讀取 data-testid;如果專案採用 data-test,需要在設定中指定 testIdAttribute。
可以先用這個順序判斷:
| 定位方式 | 優先使用時機 | 主要代價 |
|---|---|---|
getByRole、getByLabel、getByText | 測試在意使用者看見的角色、名稱或文字 | UI 文案或可存取語意改變時,測試也會失敗 |
getByTestId | 元素沒有穩定的語意定位方式,或文字是動態/不屬於測試重點 | 它不會驗證可存取角色、名稱或使用者看到的文字 |
| CSS class、DOM 結構、XPath | 只有在沒有更好選項的遺留情境 | 容易因樣式或實作重構而失效 |
因此,按鈕的文字本身是需求的一部分時,應該寫成:
await page.getByRole('button', { name: '取消訂單' }).click();
這不只找到按鈕,也會讓測試對按鈕的可存取角色與名稱保持敏感。如果測試只需要找到一個會顯示動態狀態的區塊,則 data-testid="order-status" 很適合:
await expect(page.getByTestId('order-status')).toHaveText('已取消');
Test id 的優點
- 不依賴 CSS class、DOM 層級或版面結構,前端重構時通常比較穩定。
- 不會因為文案翻譯、品牌改字或格式調整而無意義地失效。
- 可以把測試需要的定位點明確表達成前端與測試之間的契約。
- 對狀態區塊、toast、表格列、空狀態與重複元件特別實用。
Test id 的缺點
- 它是使用者看不到的實作契約,不能取代對 role、accessible name 與重要文字的驗證。
- 如果每個元素都加,頁面會充滿測試專用標記,前端需要同步維護一套額外命名。
- 過度依賴後,測試可能在 UI 已經不符合使用者需求時仍然通過。例如按鈕文字改錯了,但
data-testid沒變。 - 命名若綁定實作,例如
primary-button、div-3或包含隨機資料庫 ID,仍然會帶來不必要的變動。
實務上可以採用這幾個規則:
- 互動元素先用 role 與 accessible name;表單欄位先用 label。
- 對狀態、容器或動態內容使用穩定且描述意圖的 test id,例如
order-status、order-row、empty-state。 - 重複列表先定位資料列,再用 role、文字或欄位值縮小範圍,不要讓 test id 自己承擔所有業務識別。
- 全專案選定一種慣例,例如
data-testid或data-test,並在 Playwright 設定中統一;不要混用 CSS class 當測試定位器。
這也解釋了前面 OrderPage 的選擇:取消按鈕用 getByRole,訂單狀態用 getByTestId。前者驗證使用者可操作的語意,後者提供穩定的狀態定位點。test id 應該是語意定位器的補充,而不是取代品。
API/service:準備前置狀態
POM 與 API/service 都是 fixture 組合的同層能力。前者模擬使用者操作,後者準備測試需要的後端狀態;兩者都服務同一個 test spec,但不應互相取代。
用 API 準備資料,讓 UI 測試保留真正的重點
資料的所有權與隔離方式會在下一節整理;這裡先處理入口選擇。API/service 負責把 spec 需要的前置狀態準備好,讓測試不用繞一大圈才能抵達真正要驗證的畫面。
假設我們要測試「使用者可以取消已付款訂單」。有兩種寫法。
第一種寫法,測試從 UI 開始:登入、選商品、加入購物車、送出訂單、模擬付款,最後才取消訂單。
第二種寫法,使用測試用 API 建立一筆已付款訂單,測試從 UI 開始執行取消操作。
第二種通常更適合 E2E:
- 測試時間較短。
- 測試失敗時更容易定位在取消功能。
- 不會讓每個測試都重複驗證登入、下單與付款流程。
- 測試資料的狀態可以精準控制。
這不代表所有資料都應該直接寫資料庫。可以把準備方式分成三層:
| 準備方式 | 適合情境 | 主要代價 |
|---|---|---|
| 公開或測試 API | 正常的業務資料與狀態 | API 變更會影響測試支援程式 |
| Test service | 需要統一建立、查詢與清理的資料 | 需要維護一層測試專用介面 |
| 直接寫資料庫 | 特殊狀態、大量資料或 API 無法建立的情境 | 綁定資料表與 schema |
原則是:真正要驗證的業務操作,使用和使用者相同的入口;只是為了準備前置條件的操作,可以使用更快、更精準的測試入口。
Service 不要變成另一個萬能物件
在 E2E 專案裡,Service 通常有兩種意思,最好先命名清楚:
- 應用程式裡真正被測試的 domain service。
- 測試程式碼裡用來呼叫 API、建立資料或清理資料的 test service。
為了避免混淆,測試程式碼可以使用 OrderApi、TestDataService 或 OrderDataClient 這類名稱。
例如:
import type { APIRequestContext } from '@playwright/test';
export class OrderDataClient {
constructor(private readonly request: APIRequestContext) {}
async createPaidOrder(input: { userId: string; productId: string }) {
const response = await this.request.post('/test-support/orders', {
data: input,
});
if (!response.ok()) {
throw new Error(`Failed to create order: ${response.status()}`);
}
return (await response.json()) as { id: string };
}
async deleteOrder(orderId: string) {
const response = await this.request.delete(`/test-support/orders/${orderId}`);
if (!response.ok()) {
throw new Error(`Failed to delete order ${orderId}: ${response.status()}`);
}
}
}
這個 client 可以負責建立與清理資料,但不要把 E2E 的所有驗證都包在裡面。否則測試會看不到自己真正驗證了什麼,API 呼叫也可能在不知不覺間取代了本來應該由 UI 驗證的行為。
Data:測試狀態與隔離的基礎
測試獨立與資料所有權
前面的 API/service 已經說明資料可以從哪裡進入系統,現在要回答的是:這個情境需要什麼資料,才能在任何執行順序下得到相同的預期結果?這裡先定義 data 的邊界,再討論重建與清理策略。
「每個測試都要獨立」常被誤解成「每個測試都要建立一個全新的資料庫」。這在某些系統可行,但不是唯一方式,也不一定是最有效率的方式。
測試獨立真正要保證的是:
- 一個測試的結果,不會改變另一個測試的預期結果。
- 測試可以單獨執行,不需要依賴檔案中其他測試先跑完。
- 測試可以平行執行,不會因為共用帳號或資料而互相覆蓋。
這是「結果獨立」,不一定是「所有資源都完全獨立」。
例如,所有測試可以共用同一份國家清單或幣別資料,只要測試不會修改它。相反地,使用者、訂單、付款紀錄與購物車通常是可變資料,就不應該讓多個測試共用同一筆。
Playwright Test 會讓每個測試取得隔離的 page 與 browser context,內建的 request fixture 也會提供隔離的 API request context。[1] 這解決了瀏覽器狀態的隔離,但不會自動隔離後端資料,因此資料設計仍然是測試框架之外的責任。
在設計重建策略之前,先把測試會用到的資料分成幾類:
| 資料類型 | 例子 | 建議所有權 |
|---|---|---|
| 靜態參考資料 | 國家、幣別、商品分類 | 環境或測試套件共用 |
| 測試前置資料 | 可購買商品、測試租戶、角色 | fixture 或 worker 建立 |
| 測試操作資料 | 訂單、購物車、付款紀錄 | 每個測試自己建立 |
| 外部副作用 | 郵件、付款、物流請求 | sandbox、替身或可追蹤的測試資源 |
這張表的重點不是分類本身,而是回答兩個問題:
- 哪些測試可以安全地共用這筆資料?
- 哪個層級負責建立、使用與清理它?
如果一筆資料會被測試修改,就不應該只因為「建立它很麻煩」而放在 global setup。建立成本高,不代表它適合共用;共用可變資料往往會把成本轉移到除錯與重跑。
資料隔離與生命週期
不同系統可以採取不同的隔離與清理方式。比較實用的做法,是先理解每種策略保證了什麼,再選擇混合方案。
每次測試重置資料庫
每個測試開始前把資料庫恢復到固定狀態。若系統能讓測試在同一個受控交易中執行,也可以使用 transaction rollback;但一般跨服務 E2E 的 HTTP 請求,不能直接假設能用一個 transaction 包住整條測試。
優點是狀態容易理解,測試之間的資料污染較少。缺點是速度可能很慢,而且資料庫以外的狀態不一定能一起回復,例如 message broker、搜尋索引、檔案儲存與第三方 sandbox。
這種方式適合資料模型單純、測試環境可快速重置的服務,但不一定適合完整的多服務 E2E。
每個 worker 建立一組資源
每個 Playwright worker 建立一個測試使用者、租戶或資料 namespace,同一個 worker 裡的測試共用這些資源。
這樣可以避免每個測試重複建立昂貴的資源,也適合準備登入狀態。代價是測試必須避免修改共用的可變資料,或在每個測試底下再建立唯一的訂單與其他 entity。
worker scope 適合共用「建立成本高、內容穩定」的資源,不適合拿來共用「會被測試改變」的業務資料。
每個測試建立唯一資料
每個測試建立自己的使用者、訂單或租戶,並在識別值中加入測試批次、worker 或 UUID。
例如:
const testDataId = `e2e-${process.env.CI_JOB_ID ?? 'local'}-${crypto.randomUUID()}`;
const email = `${testDataId}@example.test`;
這種方式最適合平行執行,也最容易追蹤某筆資料屬於哪個測試。缺點是建立資料的時間較長,因此通常會搭配共用的靜態 seed 或 worker fixture。
每個測試完成後清理
測試完成後刪除自己建立的資料,可以讓測試環境保持乾淨,但不能把清理當成唯一的隔離機制。
測試可能在清理前就被程序中斷,外部服務也可能不允許立即刪除。比較可靠的策略,是同時使用:
- 唯一 namespace,避免殘留資料影響下一次測試。
- 測試批次標籤,方便找到整批資料。
- TTL 或排程清理,處理程序中斷留下的資料。
- fixture teardown,正常結束時主動清理。
清理是環境衛生;資料唯一性才是測試獨立的核心保證。
建議的實務組合
對大多數多服務 E2E 專案,我會先從這個組合開始:
- 環境啟動時建立靜態參考資料。
- 每個 worker 準備登入身分或共用的穩定資源。
- 每個測試透過 API 或 test service 建立自己的可變資料。
- 測試使用完成後的業務結果,不依賴固定 ID 或固定排序。
- 測試結束時清理,並以 namespace 或 TTL 處理清理失敗的殘留資料。
這個方案不追求每一層都完全重建,而是把昂貴資源與可變資料分開管理。
重複使用測試程式碼的界線
五層關係接起來之後,才適合討論哪些程式碼值得重複使用。抽象化的目標不是讓每個檔案都變短,而是讓上層保留意圖、下層承擔機械性細節。
測試程式碼應該重複使用,但不是所有重複都值得抽象化。
最值得抽出的通常是機械性細節:
- locator 與頁面操作。
- API request 與 response parsing。
- 測試資料的建立與清理。
- 登入狀態與角色 fixture。
- 重複的 timeout、polling 與診斷處理。
不應該過早抽出的,通常是測試情境的語意:
- 哪些步驟是這個案例特有的。
- 哪個結果是這個案例真正要驗證的。
- 為什麼要先建立某種狀態。
- 這個案例與另一個案例的業務差異。
如果抽象化後,讀者需要跳到五個檔案才能知道測試在驗證什麼,重用就已經傷害可讀性。
避免萬能 BasePage
BasePage 很容易變成所有頁面都繼承的巨大類別,裡面放登入、導覽、toast、modal、等待、截圖與各種不相關的方法。
比較好的方式是:
- 頁面共用的功能放在真正共用的 component object。
- 每個頁面只擁有自己的 locator 與操作。
- 跨頁面流程由測試或明確命名的 flow/task 組合。
避免萬能 flow method
下面這類方法需要小心:
await checkoutFlow.completeEverythingAndAssertSuccess();
它可能把登入、購物車、付款、訂單與通知全部包在一起。短期看起來很乾淨,長期卻會讓每個測試都使用同一條固定流程,無法表達不同的失敗條件。
可以抽出操作流程,但把驗證留在測試裡:
await checkoutFlow.submitOrder();
await expect(orderPage.status).toHaveText('處理中');
這樣共用的是動作,不是測試的意義。
一個可維護的 Playwright 目錄結構
目錄結構應該把前面的責任鏈呈現出來,而不是只按照類別名稱分資料夾。讀者看到 spec、fixtures、pages、services 與 factories 時,應該能推測它們之間誰使用誰、哪一層可以變動。
實際目錄不需要完全照搬,但可以用責任來組織:
tests/
├─ e2e/
│ └─ orders/
│ ├─ create-order.spec.ts
│ └─ cancel-order.spec.ts
├─ pages/
│ ├─ LoginPage.ts
│ └─ OrderPage.ts
├─ components/
│ └─ Toast.ts
├─ services/
│ ├─ OrderDataClient.ts
│ └─ UserDataClient.ts
├─ factories/
│ └─ orderFactory.ts
├─ fixtures/
│ └─ test.ts
└─ support/
├─ ids.ts
└─ cleanup.ts
可以用下面的規則判斷一段程式碼應該放哪裡:
- 跟畫面定位與操作有關,放
pages或components。 - 跟 API 建立、查詢、清理有關,放
services。 - 跟資料預設值與可覆寫欄位有關,放
factories。 - 跟資源組合與生命週期有關,放
fixtures。 - 跟特定使用者旅程有關,優先留在
e2espec,必要時才抽成命名清楚的 flow。
這樣的分類不是為了追求漂亮的目錄,而是讓程式碼的變更影響範圍比較容易預測。
平行執行會檢驗整條關係
這條責任鏈最後會在平行執行時接受檢驗:spec 是否真的獨立,取決於 fixture 的 scope;fixture 是否安全,取決於 service 建立的資料是否唯一;資料是否唯一,則取決於 data 的識別策略。
當測試開始平行執行,固定資料通常會立刻暴露問題:
- 兩個測試使用同一個 email。
- 兩個測試更新同一筆訂單。
- 一個測試刪除另一個測試正在查詢的資料。
- 測試依賴資料庫的固定排序。
因此,測試資料的識別值最好由測試執行資訊產生。例如 worker index 可以用來分割 worker 資源,UUID 可以確保同一個 worker 裡的測試資料不碰撞。Playwright 文件也示範了使用 worker index 來隔離平行測試資料的做法。[4]
但只加入 workerIndex 還不夠。每個 worker 仍然可能有多個測試,因此可變 entity 通常還需要每個測試一個唯一 ID。
一個比較完整的資料識別,可以包含:
環境 + CI job + worker + test data UUID
當測試失敗時,這個識別也能幫助我們從 log、資料庫與外部 sandbox 找回完整脈絡。
失敗後如何處理殘留資料
清理策略應該在測試設計一開始就決定,而不是等到 CI 環境塞滿資料才補救。
可以依資源類型選擇不同策略:
| 資源 | 建議策略 |
|---|---|
| 測試資料庫 entity | 以 test run ID 查詢並刪除 |
| 物件儲存檔案 | 使用測試專用 prefix,批次清理 |
| 佇列訊息 | 使用測試專用 queue 或 correlation ID |
| 付款 sandbox | 建立可查詢的測試交易,不依賴立即刪除 |
| 測試帳號 | 使用專用租戶或週期性清理 |
不要只依賴測試程式中的 afterEach。如果程序被 kill、runner 崩潰或外部 API timeout,teardown 可能無法正常完成。測試資料應該具備可辨識、可重試與可批次清理的特性。
這些抽象化會帶來什麼代價
把責任拆成五層,確實能讓變更有地方放,但也會增加間接性。這一節要看的不是「拆層好不好」,而是這條關係是否讓測試更容易理解與除錯。
POM、Service 與 Fixture 都能減少重複,但也都會增加一層間接性。
常見代價包括:
- 測試失敗時需要跳轉更多檔案才能定位。
- fixture 隱藏了測試真正的前置條件。
- service client 讓測試依賴額外的測試 API。
- factory 產生太多預設值,讀者不知道哪些欄位重要。
- 共用 flow 的一個修改,影響大量不相關測試。
所以「可重用」不是越多越好。每次抽象化前,都可以問三個問題:
- 這段程式碼是機械性重複,還是業務語意重複?
- 抽出後,測試案例是否仍然容易讀懂?
- 這個共用元件的變更,會不會影響不相關的測試?
如果答案不明確,先保留一點重複,通常比建立一個過早的共用層更容易維護。
AI 可以快速產生骨架,但不能決定邊界
AI 很適合協助建立 Playwright 的初始結構:
- 根據頁面產生 POM 的 locator 骨架。
- 根據 API schema 產生 data client。
- 根據測試情境產生 fixture 型別。
- 把重複的 setup 與 cleanup 整理成候選 fixture。
- 根據失敗 log 找出可能的資料污染或 scope 問題。
但 AI 產生的抽象化通常會傾向「把重複的程式碼全部抽出去」。這不一定是好的測試設計。
人仍然需要決定:
- 哪些資料可以共用。
- 哪些狀態必須每個測試重新建立。
- 哪些操作應該走 UI,哪些可以走 API。
- POM 的責任邊界在哪裡。
- 某個 fixture 是否把重要的前置條件藏得太深。
AI 可以幫忙找出重複;團隊要決定哪些重複代表真正穩定的抽象。
結語:把測試寫成可理解的系統
一套能長期運作的 E2E 測試,不是由大量錄製腳本堆出來的。它需要幾個清楚的邊界:
- Test spec 保留使用者情境與業務驗證。
- Fixture 組合測試需要的資源,管理 setup 與 teardown。
- POM 負責頁面互動與 locator。
- API/service 負責準備與清理後端狀態。
- Data 定義資料內容、所有權與唯一識別。
從顆粒度看,這五個位置依序是 test spec、fixture、POM、API/service 與 data;但 POM 和 API/service 在 fixture 下是同層能力。從執行責任看,則是上層提出驗證需求,下層提供可控的實作細節。少了其中一層,測試不一定立刻壞掉,但責任會開始混在一起:spec 變成 setup 腳本,fixture 藏住業務意圖,POM 開始建立資料,service 偷偷替 UI 做驗證,最後平行執行時才發現 data 根本沒有隔離。
這些分工讓測試同時具備兩種特性:可以重複使用實作細節,也不會把測試真正要驗證的事情藏起來。
Playwright 的工具可以讓測試快速啟動、平行執行與重用 fixture,但好的 E2E 架構仍然來自團隊對資料所有權與測試責任的清楚定義。
下一篇會接著討論:當測試、需求與 AI 產生的實作都需要被驗證時,驗收條件要怎麼變成真正可以檢查的交付流程?
參考資料
- [1] Playwright,〈Fixtures〉
- [2] Playwright,〈Page object models〉
- [3] Playwright,〈Authentication〉
- [4] Playwright,〈Parallelism〉
- [5] Playwright,〈Locators〉
- [6] Testing Library,〈About Queries〉