Skip to main content
E2E 測試:AI 時代的自動化測試與驗收(八)如何設計資料隔離與可維護的 Playwright 架構?

Architecture Journal

TestingPlaywrightE2E

E2E 測試:AI 時代的自動化測試與驗收(八)如何設計資料隔離與可維護的 Playwright 架構?

上一篇:E2E 只保護最重要的旅程

上一篇談的是「哪些使用者旅程值得用 E2E 保護」。這一篇往前走一步,討論另一個更容易在實務上卡住的問題:

E2E 測試要怎麼準備資料、保持測試獨立,並且讓測試程式碼可以長期維護?

當測試只有幾條時,直接在測試裡登入、建立資料、操作畫面,看起來最快。等到測試數量增加,問題就會一起出現:測試互相依賴、資料清不乾淨、平行執行不穩定,最後每個人都開始複製一份自己的登入與資料建立流程。

Playwright 提供了 PageBrowserContextAPIRequestContextfixtures,但它不會替我們決定應用程式資料如何隔離。真正重要的設計,是把測試情境、測試資料、UI 操作與生命週期管理分開。

這篇的核心原則是:

測試要獨立,靠的是資料邊界;程式碼要重用,靠的是清楚分工。

先看這些元件怎麼連在一起

這篇不是把 test spec、fixture、POM、API/service 與 data 分別介紹一遍。它們其實是在描述同一件事的不同顆粒度:一個測試案例如何從「要驗證什麼」,一路落到「用什麼資料把前置狀態準備出來」。

如果按照從大到小的顆粒度列出,我會依序看:

  1. test spec
  2. fixture
  3. POM
  4. API/service
  5. data

但這不是一條單一路徑。比較準確的關係是:test spec 使用 fixture;fixture 在同一層組合 POM、API/service 與 data。POM 和 API/service 是同層能力,data 是兩者共同操作的測試狀態。

層級它回答的問題主要責任
test spec使用者情境與驗收結果是什麼?描述測試意圖、操作流程與驗證結果
fixture這個測試需要哪些資源?活多久?組合依賴,管理 setup 與 teardown
POMUI 要怎麼操作?封裝頁面定位器與畫面操作
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 組合 OrderPageOrderDataClient,資料 fixture 負責建立和清理訂單,spec 則只取得自己要用的資源。POM 與 API/service 是 fixture 下的同層能力,兩者都服務同一個 spec,但不應互相取代。

Fixture scope 要和資料生命週期一致

Playwright fixture 最容易被誤用的地方,是 scope 選錯。

可以用一個簡單的判斷方式:

資源常見生命週期原因
PageBrowserContexttest fixture每個測試都需要獨立瀏覽器狀態
測試訂單test fixture測試會修改它的狀態
測試帳號worker 或 test fixture取決於帳號是否會被修改
靜態 seedsetup project 或 worker fixture建立一次即可共用
開發伺服器或環境webServer 或 worker-level resource不需要每個測試重啟

這裡的 testworker 是 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-testiddata-test 或其他專用屬性,可以建立穩定而明確的測試契約。Playwright 將 test id 列為內建定位方式,但同時建議優先使用貼近使用者操作的定位器;Testing Library 也把 role、label、text 等語意查找方式排在 test id 前面。[5] [6] Playwright 預設讀取 data-testid;如果專案採用 data-test,需要在設定中指定 testIdAttribute

可以先用這個順序判斷:

定位方式優先使用時機主要代價
getByRolegetByLabelgetByText測試在意使用者看見的角色、名稱或文字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-buttondiv-3 或包含隨機資料庫 ID,仍然會帶來不必要的變動。

實務上可以採用這幾個規則:

  1. 互動元素先用 role 與 accessible name;表單欄位先用 label。
  2. 對狀態、容器或動態內容使用穩定且描述意圖的 test id,例如 order-statusorder-rowempty-state
  3. 重複列表先定位資料列,再用 role、文字或欄位值縮小範圍,不要讓 test id 自己承擔所有業務識別。
  4. 全專案選定一種慣例,例如 data-testiddata-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。

為了避免混淆,測試程式碼可以使用 OrderApiTestDataServiceOrderDataClient 這類名稱。

例如:

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、替身或可追蹤的測試資源

這張表的重點不是分類本身,而是回答兩個問題:

  1. 哪些測試可以安全地共用這筆資料?
  2. 哪個層級負責建立、使用與清理它?

如果一筆資料會被測試修改,就不應該只因為「建立它很麻煩」而放在 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 專案,我會先從這個組合開始:

  1. 環境啟動時建立靜態參考資料。
  2. 每個 worker 準備登入身分或共用的穩定資源。
  3. 每個測試透過 API 或 test service 建立自己的可變資料。
  4. 測試使用完成後的業務結果,不依賴固定 ID 或固定排序。
  5. 測試結束時清理,並以 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 目錄結構

目錄結構應該把前面的責任鏈呈現出來,而不是只按照類別名稱分資料夾。讀者看到 specfixturespagesservicesfactories 時,應該能推測它們之間誰使用誰、哪一層可以變動。

實際目錄不需要完全照搬,但可以用責任來組織:

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

可以用下面的規則判斷一段程式碼應該放哪裡:

  • 跟畫面定位與操作有關,放 pagescomponents
  • 跟 API 建立、查詢、清理有關,放 services
  • 跟資料預設值與可覆寫欄位有關,放 factories
  • 跟資源組合與生命週期有關,放 fixtures
  • 跟特定使用者旅程有關,優先留在 e2e spec,必要時才抽成命名清楚的 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 的一個修改,影響大量不相關測試。

所以「可重用」不是越多越好。每次抽象化前,都可以問三個問題:

  1. 這段程式碼是機械性重複,還是業務語意重複?
  2. 抽出後,測試案例是否仍然容易讀懂?
  3. 這個共用元件的變更,會不會影響不相關的測試?

如果答案不明確,先保留一點重複,通常比建立一個過早的共用層更容易維護。

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 產生的實作都需要被驗證時,驗收條件要怎麼變成真正可以檢查的交付流程?

參考資料