iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
自我挑戰組

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

Day 13 - Worker 機制解析:平行執行原理

  • 分享至 

  • xImage
  •  

昨天我們有簡單介紹 worker-scoped fixture,但沒有特別說明 worker 到底是什麼,今天我們就來詳細介紹 Worker 跟他的平行執行原理。

Worker 是獨立的 Node.js 程序,不是瀏覽器分頁

當你執行 npx playwright test,實際發生的事情是這樣的:

                    主程序(負責排程、彙整報告)
                              │
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
          Worker 0        Worker 1        Worker 2
        (獨立程序)      (獨立程序)      (獨立程序)
              │               │               │
          瀏覽器 A         瀏覽器 B         瀏覽器 C
              │               │               │
          測試 1            測試 3            測試 5
          測試 2            測試 4            測試 6
          (依序跑)        (依序跑)        (依序跑)

主程序自己不跑測試,它只負責把測試分派出去、收集結果、最後產生報告。真正執行測試的是底下那幾個 worker,每一個都是作業系統層級的獨立程序,各自啟動自己的瀏覽器。

從這張圖可以發現下面兩件事:

第一,平行執行是發生在 worker 之間,而不是 worker 內。

Worker 0 手上的測試 1 跟測試 2 並不是同時跑的,而是測試 1 跑完才輪到測試 2,真正同時在跑的,是 Worker 0 的測試 1、Worker 1 的測試 3、Worker 2 的測試 5——三個不同程序各跑各的。

所以開了平行執行不代表你的測試全部同時發生,而是被切成幾條並行的程序去處理。

第二,獨立程序之間不共享記憶體。

因為是各自獨立的 Node.js 程序,每個 worker 都會自己載入一次你的測試檔、自己執行一次模組頂層的程式碼、自己保有一份全域變數。Worker 0 改了某個變數,Worker 1 完全不知情,它們之間並不會共享任何的記憶體空間。

小實驗:驗證模組頂層的變數不會被共享

在測試檔的最上層宣告一個計數器,每支測試都讓它加一:

// tests/worker-observe.spec.ts
import { test } from '@playwright/test';

// 模組頂層的變數
let count = 0;

test('觀察 1', async () => {
  count++;
  console.log(`[觀察 1] count=${count} pid=${process.pid}`);
});

test('觀察 2', async () => {
  count++;
  console.log(`[觀察 2] count=${count} pid=${process.pid}`);
});

test('觀察 3', async () => {
  count++;
  console.log(`[觀察 3] count=${count} pid=${process.pid}`);
});

test('觀察 4', async () => {
  count++;
  console.log(`[觀察 4] count=${count} pid=${process.pid}`);
});

test('觀察 5', async () => {
  count++;
  console.log(`[觀察 5] count=${count} pid=${process.pid}`);
});

test('觀察 6', async () => {
  count++;
  console.log(`[觀察 6] count=${count} pid=${process.pid}`);
});

如果所有測試都跑在同一個地方,count 應該會乖乖地從 1 數到 6。

但實際跑起來,你會看到好幾個測試的 count 都是 1,而且它們的 pid(程序編號)各不相同:

Running 6 tests using 4 workers
[chromium] › tests/worker-observe.spec.ts:16:5 › 觀察 3
[觀察 3] count=1 pid=52956
[chromium] › tests/worker-observe.spec.ts:26:5 › 觀察 5
[觀察 5] count=2 pid=52956
[chromium] › tests/worker-observe.spec.ts:31:5 › 觀察 6
[觀察 6] count=3 pid=52956
[chromium] › tests/worker-observe.spec.ts:21:5 › 觀察 4
[觀察 4] count=1 pid=52955
[chromium] › tests/worker-observe.spec.ts:11:5 › 觀察 2
[觀察 2] count=1 pid=52954
[chromium] › tests/worker-observe.spec.ts:6:5 › 觀察 1
[觀察 1] count=1 pid=52953

pid 是作業系統給每個程序的唯一編號。看到不同的 pid,就等於這些測試真的跑在不同的程序裡,而 count 各自從 1 開始,則證明了每個 worker 都是從零開始載入這個檔案的,它們手上的 count 是完全獨立的,並不是同一個。

這個實驗也間接地告訴我們,不要用模組層級的變數在測試之間傳遞資料,因為在只有一個 worker 的時候測試可能沒問題,但一旦平行化,就會莫名其妙壞掉,而且很難排查。

Playwright 到底會開幾個 worker?

知道 worker 是什麼之後,下一個問題就是:它到底預設會開幾個?

Playwright 的預設值是邏輯 CPU 核心數的一半。可以先把自己機器的核心數印出來:

node -e "console.log(require('os').cpus().length)"

如果不想用預設值,有三種方式可以自己指定:

// playwright.config.ts
export default defineConfig({
  workers: 4,      // 固定開 4 個
  // workers: '50%',  // 也可以用百分比寫法,依機器核心數換算
});
# 或是執行時用 CLI 參數臨時覆寫(優先權高於 config)
npx playwright test --workers=3

回頭看我們自己的設定

我們的 playwright.config.ts 從一開始就有這行:

/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,

翻譯過來就是:在 CI 環境上只開 1 個 worker(等於關掉平行),在本機則交給 Playwright 自己決定。

為什麼 CI 要特別限制?因為 CI 的執行環境通常資源很有限。每個 worker 都要啟動一份瀏覽器,在小機器上開太多 worker,它們會互相搶 CPU 跟記憶體,結果就是每支測試都變慢、動作反應不及,最後演變成「本機跑都過、CI 上卻時好時壞」的狀況。

我們可以得出一個結論:worker 不是開越多越快。

每多一個 worker,就多一個 Node.js 程序加一份瀏覽器的記憶體開銷。如果超過機器負荷之後,增加 worker 只會讓所有測試一起變慢,甚至開始出現原本不會發生的失敗。因此重點是找到適合自己機器的數量,而不是一味調高。

測試怎麼被分配:fullyParallel 改變了什麼

現在回到 Day 12 worker-scope-demo.spec.ts 裡的三支測試,他們明明在同一個檔案,為什麼會被拆到不同 worker上執行?

關鍵在這個設定:

// playwright.config.ts
fullyParallel: true,

這個設定決定了 Playwright 分配測試的基準單位是什麼:

fullyParallel: false(預設) fullyParallel: true(我們的設定)
分配基準單位 檔案 單一測試
同檔案的測試 保證在同一個 worker 裡依序執行 可能被拆散到不同 worker 上執行
平行程度 檔案之間平行 測試之間平行
worker-scoped fixture 建立幾次 每個 worker 一次 每個 worker 一次(但 worker 用得更多)

沒開 fullyParallel 時,Playwright 是把「一整個檔案」交給一個 worker,檔案裡的測試在那個 worker 裡排隊跑完。開啟之後,分配單位縮小到「單一測試」,同一個檔案的測試就可能落在不同 worker 上執行。

這就是 Day 12 那個 log 印超過一次的直接原因。 三支測試被分到不同 worker,而 worker-scoped fixture 是「每個 worker 準備一份」,所以有幾個 worker 接到測試,>>> 執行登入流程 就印幾次。

如果某幾支測試真的有先後順序依賴(例如必須先建立資料才能刪除),可以用 test.describe.configure({ mode: 'serial' }) 把它們綁在同一個 worker 依序執行。不過測試之間互相依賴本身就不是好設計,這裡知道有這個工具就好。

動手實測:讓 worker 現形

概念講到這裡,直接用 log 看一次最快。在剛剛的測試檔加入 workerIndex 的 log:

// tests/worker-observe.spec.ts
import { test } from '@playwright/test';

let count = 0;

for (const n of [1, 2, 3, 4, 5, 6]) {
  test(`觀察 ${n}`, async ({}, testInfo) => {
    count++;
    console.log(
      `[觀察 ${n}] worker=${testInfo.workerIndex} count=${count} pid=${process.pid}`
    );
  });
}

這裡用迴圈產生 6 支測試。

測試函式的第二個參數 testInfo 是 Playwright 提供的測試資訊物件,裡面的 workerIndex 就是這支測試跑在幾號 worker 上的資訊。它會是 0、1、2 這種數字,接下來用它當主要的觀察標籤。

對照組一:只開一個 worker

npx playwright test worker-observe --project=chromium --workers=1
Running 6 tests using 1 worker
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 1
[觀察 1] worker=0 count=1 pid=53240
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 2
[觀察 2] worker=0 count=2 pid=53240
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 3
[觀察 3] worker=0 count=3 pid=53240
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 4
[觀察 4] worker=0 count=4 pid=53240
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 5
[觀察 5] worker=0 count=5 pid=53240
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 6
[觀察 6] worker=0 count=6 pid=53240

預期會看到:所有測試的 worker 都是 0、pid 全部相同、count 從 1 老實數到 6,而且 log 是照著觀察 1 到觀察 6 的順序印出來的。

對照組二:開三個 worker

npx playwright test worker-observe --project=chromium --workers=3
Running 6 tests using 3 workers
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 1
[觀察 1] worker=0 count=1 pid=53434
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 3
[觀察 3] worker=2 count=1 pid=53432
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 4
[觀察 4] worker=0 count=2 pid=53434
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 6
[觀察 6] worker=2 count=2 pid=53432
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 5
[觀察 5] worker=0 count=3 pid=53434
[chromium] › tests/worker-observe.spec.ts:6:9 › 觀察 2
[觀察 2] worker=1 count=1 pid=53433

這次會看到三件事同時發生:

  1. worker 出現 0、1、2 三種值,pid 也是三個不同的數字。
  2. count 不再連續——每個 worker 各自從 1 開始數自己的。
  3. log 的順序是亂的,觀察 4 可能比觀察 2 先印出來。

第三點特別值得注意:log 交錯本身就是平行執行最直接的證據。如果是依序執行,輸出順序不會亂掉。

對照組三(選配):關掉 fullyParallel

我們暫時把 config 裡的 fullyParallel 改成 false 再跑一次。這時候 6 支測試在同一個檔案裡,會被整包分給同一個 worker。即使你給了 --workers=3,也只會有一個 worker 在做事,因為總共只有一個檔案可以分。

驗證完記得改回 true。

有沒有辦法「全部測試加起來只登入一次」?

如果你開了 3 個 worker,那就是 3 次登入。雖然這已經比「每支測試都登入一次」省下很多時間,但如果我們想追求極致,一定會問:有沒有辦法讓「全部測試加起來,真的只要登入一次」就好?

答案是光靠 fixture 做不到。因為 worker 之間是獨立程序、彼此不共享記憶體。Worker 0 登入完的瀏覽器狀態,無法直接傳給 Worker 1 使用。

要跨越程序邊界,唯一的做法是:將登入狀態存成實體檔案,讓所有 worker 都能去讀取。 這也是實務上最常使用的登入優化技巧,我們會在後續的文章中詳細介紹。

順帶一提,平行執行還會帶出另一個實務問題:如果每個 worker 都需要自己專屬的測試帳號,避免多個 worker 同時操作同一個帳號互相干擾,該怎麼分配?Playwright 有對應的機制,等到談多帳號登入時再說。

今日總結

今天我們詳細介紹了 worker 的機制,它是一個獨立的 Node.js 程序,各自啟動瀏覽器、各自載入測試檔、而且彼此不共享記憶體。平行處理是發生在 worker 之間,同一個 worker 內的測試則會依序執行。

最後,特別提醒兩個關於 worker 的重點觀念:

  1. worker 不是開越多,測試就跑越快: worker 的數量預設是 CPU 核心數的一半,你雖然可以用 config 或 CLI 強制調高,但受限於機器的 CPU 與記憶體,超過負荷後只會讓所有測試一起變慢,甚至增加彼此干擾的風險。
  2. 同檔案的測試不保證照順序跑: 開啟 fullyParallel: true 後,分配的基準單位從「檔案」縮小到了「單一測試」。同一個檔案的測試可能會被拆散到不同 worker 上,並且以任意順序開始執行(這也是為什麼 Day 12 的登入 log 會印出超過一次的原因)。因此要使用平行測試的前提是每一支測試都必須能夠獨立執行,不該有順序上的相依。

明天我們會介紹讓測試在同一個 worker 裡面依序執行的方法以及各種不同的context, page的共用方式。


上一篇
Day 12 - Fixture 進階:相依注入與 Scope
下一篇
Day 14 - 共用的層級:serial mode 與 context / page 的選擇
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言