昨天我們有簡單介紹 worker-scoped fixture,但沒有特別說明 worker 到底是什麼,今天我們就來詳細介紹 Worker 跟他的平行執行原理。
當你執行 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 的時候測試可能沒問題,但一旦平行化,就會莫名其妙壞掉,而且很難排查。
知道 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 依序執行。不過測試之間互相依賴本身就不是好設計,這裡知道有這個工具就好。
概念講到這裡,直接用 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 這種數字,接下來用它當主要的觀察標籤。
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 的順序印出來的。
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
這次會看到三件事同時發生:
worker 出現 0、1、2 三種值,pid 也是三個不同的數字。count 不再連續——每個 worker 各自從 1 開始數自己的。第三點特別值得注意: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 的重點觀念:
fullyParallel: true 後,分配的基準單位從「檔案」縮小到了「單一測試」。同一個檔案的測試可能會被拆散到不同 worker 上,並且以任意順序開始執行(這也是為什麼 Day 12 的登入 log 會印出超過一次的原因)。因此要使用平行測試的前提是每一支測試都必須能夠獨立執行,不該有順序上的相依。明天我們會介紹讓測試在同一個 worker 裡面依序執行的方法以及各種不同的context, page的共用方式。