從 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 |
今天我們要透過實驗解答三個問題:
此外,還有一個我在測試的時候偶爾會碰到的情況:測試「假性通過」,也就是明明應該失敗或是成功的測試,卻因為時間差而通過或是失敗。這部分也會在接下來的實作部分一起說明。
為了確保比較基準的公平性,三個版本的測試內容完全相同,僅替換傳入的 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();
});
loggedInPage 替換為 workerLoggedInPage。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 |
看看上面的實測數據,你會發現:
about:blank),每次 goto('/') 都必須重新載入單頁應用(SPA)的 JavaScript Bundle 與進行 DOM 樹初始化。在現代前端框架中,這段初始化的耗時可能就抵銷掉了登入省下的時間。因此我們不能只看執行效率,而是要綜合考量各種效益與付出的成本,選擇最適合的共用方案。
當我們盲目追求共用時,最常遭遇的就是測試隔離性(Test Isolation)所引發的各種錯誤。自動化測試的「隔離性」可以拆解為三個維度:
┌─────────────────────────────────────────────────────────────┐
│ 1. 畫面層級 (UI State): 輸入框關鍵字、Modal 開啟、選單滾動位置 │
├─────────────────────────────────────────────────────────────┤
│ 2. 瀏覽器層級 (Storage State): Cookie, LocalStorage, Session │
├─────────────────────────────────────────────────────────────┤
│ 3. 資料庫層級 (Data State): 後端 DB 資料、建立出的實體物件 │
└─────────────────────────────────────────────────────────────┘
接下來我們看看這三個維度是如何在不同版本的測試中作用的。
在真實開發中,工程師很少會在測試結束時手動寫程式「把搜尋框清空」。我們修改巡檢 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 的產生:
- 錯誤訊息可能具有誤導性:報告顯示巡檢 2 斷言失敗,但真正的兇手是上一支巡檢 1 留下的
Black關鍵字,而不是這邊說的問題。- 偶發性爆炸 (Flaky Test):在開啟
fullyParallel: true多 Worker 時,當兩支測試被分配到不同 Worker 時不會有問題,因為兩邊使用的測試畫面是獨立的。而當被分配到同一個 Worker 時,因為會共用 Page,所以在巡檢 2 時就會出錯。- 除錯方法:當遇到這種神祕的偶發失敗時,加上
--workers=1鎖定單一 Worker 固定順序跑。如果轉為穩定失敗,就可以肯定是測試間的隱形相依性(Cross-test Pollution)導致的。
除了剛剛說的問題外,共用Page會有的另一個問題是,當版本 B 爆掉時,你無法取得失敗當下的影片跟 Trace。
還記得 Day 15 講過的底層原理嗎?
直覺上,版本 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 的所有分頁之間共用的,所以:
Black 被寫進 localStorage。filter={"q":"Black"} 還原回網址與搜尋框。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 策略,把焦點從「純粹的執行秒數」轉轉移到測試程式的品質上:
listParams)時,看似單純的「畫面殘留」其實是 Storage 殘留,共用Context的方案環境都會被污染。下一階段開始我們會開始處理後台管理系統中最常見的複雜互動——表單、下拉選單與彈跳視窗(Modal)的操作細節!