iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

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

Day 16 - 緩衝日②:效能與隔離性的取捨實驗

  • 分享至 

  • xImage
  •  

從 Day 11 到 Day 15,我們介紹了 Playwright 的 Fixture 機制與 Worker 平行化原理:從基本的 fixture 自動注入登入、worker scope 共用 workerLoggedInPage,到探討 serial mode 與 sharedContext、 sharedPage,以及各個除錯產出物(screenshot, video, trace)所綁定的層級與陷阱。

這些觀念聽起來都很實用,但回到現實的工作情境中,許多人常常以為:「只要能共用登入、減少重複操作,測試一定跑得比較快、效率更高!」

但在自動化測試中,執行時間雖重要,但不是唯一的評估指標,更重要的是測試隔離性(Test Isolation)與除錯防禦力。

今天作為第二個緩衝日,我們會用實際的後台商品流程來實測三種 Fixture 策略,探討比起執行時間更重要的核心議題:測試隔離性(Test Isolation)的三個維度、Flaky Test、以及為了省下幾秒鐘所付出的「除錯防禦力」代價。

情境題目

我們利用 react-admin demo 的後台商品情境,撰寫三支簡單的巡檢測試(搜尋商品、確認列表預設資料、進入建立商品頁),然後分別套用三種不同的 Fixture 策略執行:

版本 採用的 Fixture 策略說明
版本 A loggedInPage (test scope) Playwright 預設思維:每支測試都是全新的 context/page,各自獨立執行登入
版本 B workerLoggedInPage (worker scope, 共用 page) Day 12 的做法:整個 Worker 共用同一個 Page 與 Context,僅登入一次
版本 C sharedPage (test scope, 共用 context, 不共用 page) Day 14 的做法:整個 Worker 只登入一次(共用 Context),但每支測試開啟全新 Page

今天我們要透過實驗解答三個問題:

  1. 版本 B 與版本 C 相較於預設的版本 A,實際數據呈現什麼趨勢?
  2. 為什麼單純「比較執行時間」在實務上往往不具決定性意義?
  3. 當共用導致隔離性出現破洞時,對維護性與除錯(Debuggability)會造成什麼影響?

此外,還有一個我在測試的時候偶爾會碰到的情況:測試「假性通過」,也就是明明應該失敗或是成功的測試,卻因為時間差而通過或是失敗。這部分也會在接下來的實作部分一起說明。

實作

測試素材:同一組測試,三個版本

為了確保比較基準的公平性,三個版本的測試內容完全相同,僅替換傳入的 Fixture 參數。

以版本 A 為例:

// playwright-tests/tests/day16-version-a.spec.ts
import { test, expect } from '../fixtures/fixtures';
import { ProductsPage } from '../pages/ProductsPage';

test('巡檢 1:搜尋既有商品,結果不為空', async ({ loggedInPage }) => {
  const productsPage = new ProductsPage(loggedInPage);
  await productsPage.goto();
  await productsPage.search('Black');
  await expect(productsPage.resultCount).toBeVisible();
});

test('巡檢 2:商品列表預設顯示資料', async ({ loggedInPage }) => {
  const productsPage = new ProductsPage(loggedInPage);
  await productsPage.goto();
  await expect(productsPage.resultCount).toBeVisible();
});

test('巡檢 3:可以進入建立商品頁', async ({ loggedInPage }) => {
  const productsPage = new ProductsPage(loggedInPage);
  await productsPage.goto();
  await productsPage.clickCreate();
  await expect(loggedInPage.getByRole('heading', { name: 'Create Poster' })).toBeVisible();
});
  • 版本 B:把測試參數的 loggedInPage 替換為 workerLoggedInPage。
  • 版本 C:把測試參數替換為 sharedPage。(注意:版本 C 每支測試開頭必須手動呼叫 await sharedPage.goto('/')!因為新開啟的分頁預設是 about:blank 空白頁,必須先載入主頁後,頁面上的選單點擊才能運作,這個本身就是 Page 隔離性的直接展現)。

量測方式與實驗數據

我們固定使用 --workers=1 鎖定單一 Worker,每個版本跑 3 次並取中位數。在 react-admin demo(本地 http://localhost:8000)環境下實測數據如下:

版本 採用的 Fixture 登入次數 第 1 次 第 2 次 第 3 次 中位數
版本 A loggedInPage (各自登入) 3 次 4.7s 4.1s 3.8s 4.1s
版本 B workerLoggedInPage (共用 page) 1 次 2.7s 1.9s 1.7s 1.9s
版本 C sharedPage (共用 context) 1 次 4.3s 4.2s 4.3s 4.3s

為什麼「比時間」是個假議題?

看看上面的實測數據,你會發現:

  1. 版本 C (4.3s) 沒有比版本 A (4.1s) 快
    明明版本 C 只登入了一次,為什麼總時間反而差不多甚至略慢?因為版本 C 的每支測試都開了一個全新分頁(about:blank),每次 goto('/') 都必須重新載入單頁應用(SPA)的 JavaScript Bundle 與進行 DOM 樹初始化。在現代前端框架中,這段初始化的耗時可能就抵銷掉了登入省下的時間。
  2. 絕對秒數的省時公式有其邊界限制:
    我們常說省時公式是:
    $$\text{省下的時間} \approx (\text{每個 Worker 的測試數} - 1) \times \text{單次登入耗時}$$
    如果你的專案登入需要打真實 API、過 2FA,一次要 5 秒,那共用登入省下的時間確實可觀;但如果登入本身很快、或者測試數量不多,盲目為了「省時間」去切換共用策略,效益可能微乎其微,甚至會讓維護更困難。

因此我們不能只看執行效率,而是要綜合考量各種效益與付出的成本,選擇最適合的共用方案。

測試隔離性的三個維度與 Flaky Test

當我們盲目追求共用時,最常遭遇的就是測試隔離性(Test Isolation)所引發的各種錯誤。自動化測試的「隔離性」可以拆解為三個維度:

┌─────────────────────────────────────────────────────────────┐
│ 1. 畫面層級 (UI State): 輸入框關鍵字、Modal 開啟、選單滾動位置   │
├─────────────────────────────────────────────────────────────┤
│ 2. 瀏覽器層級 (Storage State): Cookie, LocalStorage, Session │
├─────────────────────────────────────────────────────────────┤
│ 3. 資料庫層級 (Data State): 後端 DB 資料、建立出的實體物件       │
└─────────────────────────────────────────────────────────────┘

接下來我們看看這三個維度是如何在不同版本的測試中作用的。

陷阱一:畫面層級殘留 (UI State)導致測試錯誤失真

在真實開發中,工程師很少會在測試結束時手動寫程式「把搜尋框清空」。我們修改巡檢 1,讓它搜尋關鍵字後故意不清空搜尋條件:

// playwright-tests/tests/day16-version-b.spec.ts
test('巡檢 1:搜尋既有商品,結果不為空', async ({ workerLoggedInPage }) => {
  const productsPage = new ProductsPage(workerLoggedInPage);
  await productsPage.goto();
  await productsPage.search('Black');
  await expect(productsPage.resultCount).toBeVisible();
  // 故意不清掉搜尋條件就結束測試!
});

test('巡檢 2:商品列表預設顯示完整資料', async ({ workerLoggedInPage }) => {
  const productsPage = new ProductsPage(workerLoggedInPage);
  await productsPage.goto();
  // 預期在預設列表中能看到首頁常態商品 Aerial Coast
  await expect(productsPage.productCard('Aerial Coast')).toBeVisible();
});

⚠️ 實驗前的關鍵前提:search() 必須等到搜尋真的生效

這個實驗我第一次跑的時候,三支測試竟然全部通過。檢查了一下,問題是出在 ProductsPage 的 search():

// ❌ 原始版本:fill() 完就返回
async search(keyword: string) {
    await this.searchInput.fill(keyword);
}

react-admin 的 FilterLiveSearch 有 500ms 的 debounce,fill() 返回時 filter 根本還沒寫進網址與 store。而巡檢 1 接著斷言的 resultCount 本來就在畫面上,毫秒級就通過 → 巡檢 1 結束時 localStorage 的 RaStoreECommerce.products.listParams 仍是 null,填的資料還沒有進localStorage中,也就沒有殘留的問題存在。

此外還有第二層時間差,從「使用者輸入完」到「畫面真的變成搜尋結果」之間,其實有兩段性質完全不同的時間差。我在瀏覽器內用 MutationObserver + requestAnimationFrame 打點實測:

階段 實測區間 這個數字是怎麼來的
① 輸入完成 → filter 寫進網址與 store 554~586ms react-admin useListParams 的 debounce = 500 是寫死的常數(等使用者停止輸入 500ms 才真正送出搜尋),再加上 React 處理與 navigate() 的數十毫秒
② filter 寫進網址與 store → 重新抓資料並重繪完成 359~375ms 這份 demo 的 fakeServer/rest.ts 寫死了 withDelay(300) 模擬 API 延遲,才會穩定落在這個區間;真實專案取決於 API 往返、資料量與渲染成本,每一次都不一樣

第 ① 段是可預期的常數,第 ② 段在實務上是不可預期。而巡檢 2 的斷言在點完選單後約 30ms 就跑完了,遠早於這兩段時間差結束,而Playwright 的自動重試斷言只要「有一瞬間成立」就算通過,所以它抓到的是那個還沒被過濾掉的舊畫面,所以測試才會通過。

第 ② 段所需的時間因為沒有固定值,所以不能使用 waitForTimeout(1000) 硬等一個猜出來的時間,而應該用 web-first assertion 去等「狀態真的變了」:

// ✅ 正確版本:playwright-tests/pages/ProductsPage.ts
async search(keyword: string) {
    const before = await this.resultCount.innerText();
    await this.searchInput.fill(keyword);
    // ① 等 debounce 落地:網址帶上 filter
    await expect(this.page).toHaveURL(new RegExp(`filter=.*${keyword}`, 'i'));
    // ② 等列表真的重新渲染完成(筆數改變)
    await expect(this.resultCount).not.toHaveText(before);
}

這其實是 Day 10 Web-First Assertion 觀念的延伸:POM 的「操作方法」如果沒把非同步副作用等完就返回,測試之間的污染會被時間差蓋掉,讓你誤以為測試都成功。

調整完 search() 之後,版本 B(共用 Page) 跑起來的結果就是穩定失敗的:

  1) [chromium] › tests/day16-version-b.spec.ts:11:5 › 巡檢 2:商品列表預設顯示完整資料 ────

    Error: expect(locator).toContainText(expected)

    Locator: getByTestId('product-result-count')
    Expected string: "1-12 of 120"
    Received string: "1-1 of 1"

      13 |   const productsPage = new ProductsPage(workerLoggedInPage);
      14 |   await productsPage.goto();
    > 15 |   await expect(productsPage.resultCount).toContainText('1-12 of 120');
         |                                                  ^

⚠️ 排查死角與 Flaky Test 的產生:

  1. 錯誤訊息可能具有誤導性:報告顯示巡檢 2 斷言失敗,但真正的兇手是上一支巡檢 1 留下的 Black 關鍵字,而不是這邊說的問題。
  2. 偶發性爆炸 (Flaky Test):在開啟 fullyParallel: true 多 Worker 時,當兩支測試被分配到不同 Worker 時不會有問題,因為兩邊使用的測試畫面是獨立的。而當被分配到同一個 Worker 時,因為會共用 Page,所以在巡檢 2 時就會出錯。
  3. 除錯方法:當遇到這種神祕的偶發失敗時,加上 --workers=1 鎖定單一 Worker 固定順序跑。如果轉為穩定失敗,就可以肯定是測試間的隱形相依性(Cross-test Pollution)導致的。

陷阱二:共用 Page 帶來的隱形代價——喪失除錯防禦力 (Debuggability)

除了剛剛說的問題外,共用Page會有的另一個問題是,當版本 B 爆掉時,你無法取得失敗當下的影片跟 Trace。

還記得 Day 15 講過的底層原理嗎?

  • 影片(Video)要等 Page 關閉才會寫入定稿。
  • 在版本 B 中,整個 Worker 期間 Page 一直活著直到所有測試結束。這導致測試 2 因為某些因素失敗時,Playwright 無法自動為測試 2 切出一支獨立的失敗影片,導致錯誤排查時間增加且增添麻煩。

陷阱三:開全新 Page 就萬無一失?版本 C 一樣失敗

直覺上,版本 C(sharedPage,共用 Context、不共用 Page)每支測試都拿到全新的 Page,搜尋框這種 UI State 應該被完美隔離才對。

但實測結果是:版本 C 的巡檢 2 一樣失敗。

原因是在 react-admin demo 的 App.tsx:

const store = localStorageStore(undefined, 'ECommerce');

react-admin 會把列表的篩選、排序、分頁狀態(listParams)寫進 localStorage(key 為 RaStoreECommerce.products.listParams)。而 localStorage 是綁在 Context 上、在同一個 Context 的所有分頁之間共用的,所以:

  1. 巡檢 1 的 Black 被寫進 localStorage。
  2. 巡檢 2 開了一個全新分頁,DOM 是乾淨的沒錯,但 react-admin 一掛載就從 localStorage 把 filter={"q":"Black"} 還原回網址與搜尋框。
  3. Aerial Coast 一樣被過濾掉,測試失敗。

💡 這個實驗的價值:在我們使用的這個測試網頁中,我們以為「搜尋條件」是第 1 層的畫面殘留,但在 react-admin 這類把 UI 狀態持久化的框架裡,它其實是第 2 層的 Storage 殘留。污染屬於哪一層,決定了要使用哪一種隔離策略,這種會因應要測試的專案而有不同的策略,必須實際測試過才會知道。

至於第 3 層就更難處理了:如果把測試改為「巡檢 1 建立一筆商品」,在串接真實後端的專案中,巡檢 1 寫入 DB 的資料,即使開全新 Context、甚至全新瀏覽器,巡檢 2 重新載入時依然會拉到那筆殘留的資料。

這也證明了開新分頁只能隔離純 DOM 狀態的資料殘留,無法避免 Storage 與 Database 的資料殘留,對於更後端的資料殘留必須依賴測試資料的管理策略(後面會再詳細介紹),才能有效避免髒資料。

結論:工程取捨矩陣與終極解法

將三種策略的權衡整理如下:

評估指標 版本 A (預設 loggedInPage) 版本 B (workerLoggedInPage) 版本 C (sharedPage)
純 DOM 狀態隔離 ✅ 完全隔離 ❌ 殘留污染 (致命) ✅ 完全隔離
Storage 隔離 ✅ 完全隔離 ❌ 共享殘留 ❌ 共享殘留
實測本次巡檢 2 ✅ 通過 ❌ 失敗 ❌ 失敗
Per-test 影片/Trace ✅ 內建全自動支援 ❌ 無法自動取得 ⚠️ 需手動寫程式 attach
除錯防禦力 極高 (產出物齊全) 極低 (無獨立影片) 中等 (程式碼複雜)
建議適用場景 95% 的測試情境、開發除錯期 不太建議使用 純讀取巡檢,且框架未把 UI 狀態寫進 Storage

注意:版本 C 的「開新分頁」只適用於 DOM 狀態的隔離,如果像 react-admin 這種把列表篩選/排序/分頁寫進 localStorage 的框架,共用 Context 就等於共用這些狀態,新分頁一樣會被污染。

雖然把三種不同共用狀態的優缺點列出,但實際上並沒有說哪種策略絕對是最好的,實務上常會因為各種情況而不得不去選擇隔離性較不理想的方案,要清楚了解各種做法可能的風險所在,才能夠在面對不同情況選擇最適合的策略以及在遇到問題時知道可以從哪些地方去除錯。

今日總結

今天我們透過一組後台巡檢測試與三種 Fixture 策略,把焦點從「純粹的執行秒數」轉轉移到測試程式的品質上:

  1. 比時間是個假議題:單純看執行秒數容易忽略 SPA 重新載入與初始化耗時,實際上更應關注隔離性與維護成本。
  2. 隔離性的三個維度:開新 Page 只解決得了純 DOM 狀態,解決不了 Storage 與 Database 殘留;而且當框架把 UI 狀態持久化(如 react-admin 的 listParams)時,看似單純的「畫面殘留」其實是 Storage 殘留,共用Context的方案環境都會被污染。
  3. 除錯防禦力的代價:共用 Page 雖然省了微小時間,卻犧牲了 per-test 影片與 Trace,會讓未來的排查成本成倍暴增。

下一階段開始我們會開始處理後台管理系統中最常見的複雜互動——表單、下拉選單與彈跳視窗(Modal)的操作細節!


上一篇
Day 15 - 除錯產出物:screenshot、video 與 trace
下一篇
Day 17 - 下拉選單與彈跳視窗:浮層元素的定位
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言