並行和隔離測試執行
使用 --parallel 跨 CPU 核心執行測試檔案,使用 --isolate 將檔案彼此隔離,在一個檔案內並行執行測試,並使用 --shard 和 --timings 將測試套件拆分到 CI 機器上
bun test 有三個彼此獨立的開關,用於同時執行多個專案:
| Flag | 並行單位 | 功能說明 |
|---|---|---|
--parallel[=N] | 測試檔案,在行程中執行 | 在 N 個工作行程中執行檔案(預設值:CPU 核心數)。隱含啟用 --isolate;使用 --no-isolate 可退出隔離模式。 |
--concurrent / test.concurrent | 一個檔案內的測試 | 當同一檔案中的 async 測試正在等待時,允許它們重疊執行。 |
--shard=i/n | 測試檔案,跨機器執行 | 執行測試套件中確定性切分後的第 i 份,共 n 份。與 --timings 結合使用可根據耗時進行平衡。 |
它們可以組合使用:一個 CI 任務可以執行 bun test --shard=2/4 --parallel,而該分片中的檔案仍然可以包含 test.concurrent 測試
--parallel
bun test --parallel # one worker per CPU core
bun test --parallel=4 # exactly 4 workers
主 bun test 行程會變成一個協調器。它照常發現測試檔案,然後啟動工作行程,並一次向每個行程分配一個檔案。每個測試完成後,結果會流式傳回,因此輸出看起來與序列執行相同。協調器會將每個檔案的結果放在其檔名下方一起列印,並且絕不會將某個測試的 console.log 輸出與另一個檔案的輸出交錯顯示。
bun test v1.4.0 8x PARALLEL
src/router.test.ts:
✓ matches static routes [0.31ms]
✓ matches params [0.12ms]
src/db.test.ts:
✓ migrates up [41.02ms]
✓ migrates down [38.60ms]
...
工作行程會惰性啟動。第一個工作行程會立即啟動;只有當每個正在執行的工作行程都持續忙碌幾毫秒後,協調器才會啟動其餘行程(--parallel-delay=<ms>,預設值為 5)。因此,一組很小的檔案會在單個工作行程中執行,不會產生啟動行程的開銷;而第一個耗時較長的檔案會觸發全部工作行程啟動。
檔案如何分配
協調器會按路徑對檔案排序,並將檔案拆分為每個工作行程各自連續的一塊,因此位於同一目錄中的檔案通常會落在同一個行程中,因為它們一般會匯入相同的模組(分塊邊界可能落在目錄中間,被轉移的檔案也會移動)。當某個工作行程處理完自己的檔案塊後,它會從另一個工作行程剩餘的最大檔案塊中取走後半部分。使用 --timings 時,協調器會按記錄的耗時而非檔案數量切分檔案塊;每個工作行程會先啟動耗時最長的檔案,而空閒的工作行程會從剩餘時間最多的檔案塊中取走尚未啟動且耗時最長的檔案。
每個檔案都會被隔離(除非選擇退出)
--parallel 隱含啟用 --isolate:即使兩個檔案落在同一個工作行程上,每個檔案也會在全新的全域性物件中執行。使用 --parallel 通過的測試不會相依前一個檔案洩漏的狀態
--parallel --no-isolate 會關閉此功能:每個工作行程會為分配給它的所有檔案保留一個全域性物件和一個模組登錄檔,就像序列執行 bun test 時整個測試套件的行為一樣。每個工作行程只會求值一次匯入內容(以及 --preload 模組),而不是每個檔案都求值一次,這是執行大量小檔案測試套件最快的方式。代價是某個檔案可能會觀察到同一工作行程上較早執行的檔案遺留下來的內容。預載入級別的 beforeAll/afterAll 鉤子仍會包裹每個檔案,因為工作行程無法知道哪個檔案是它要執行的最後一個檔案。
工作行程環境
每個工作行程都會將 BUN_TEST_WORKER_ID 和 JEST_WORKER_ID 設定為從 1 開始的索引,因此測試可以為每個工作行程選擇不同的資料庫、埠範圍或臨時目錄:
const dbName = `app_test_${process.env.BUN_TEST_WORKER_ID ?? "1"}`;
影響測試執行方式的標誌(--timeout、--preload、--define、--coverage、--update-snapshots、-t、--retry、--rerun-each、--concurrent、--randomize/--seed,……)都會轉發給工作行程。協調器會按檔案粒度處理 --bail:達到失敗閾值後,它不會再啟動新檔案,但已經在執行的檔案會繼續完成。
協調器會合並覆蓋率、JUnit XML 和快照寫入,因此 --parallel --coverage --reporter=junit --reporter-outfile=junit.xml 會生成一份報告。
如果工作行程崩潰(某個測試呼叫了 process.exit),協調器會將該工作行程當時正在執行的檔案報告為失敗,並由替代工作行程接手剩餘檔案。致命訊號導致的崩潰會中止整個執行,因此後續通過的檔案無法掩蓋它。有效工作行程只有一個時(--parallel=1,或測試套件只有一個測試檔案),bun test 會在主行程中執行檔案,因此 process.exit 會以該退出程式碼結束整個執行。
--parallel 何時有幫助,何時沒有
當測試套件的主要耗時來自測試執行環境,--parallel 會有明顯收益,例如 I/O 等待、實際計算、子行程或大量檔案。但它也有開銷:每個檔案都會在全新的全域性物件中重新求值其匯入內容(參見 --isolate),而且每個工作行程都是獨立行程,都需要單獨進行 JIT 預熱。對於一組執行非常快且都匯入同一個大型模組圖的檔案,普通的 bun test(一個行程,共享一個模組登錄檔)可能更快。兩種方式都試試;Bun 會在每次執行結束時列印耗時資料。
--isolate
bun test --isolate
在同一個行程內的全新 JavaScript 全域性物件中執行每個測試檔案。在檔案之間,Bun 會:
- 建立新的
globalThis(因此檔案附加到globalThis上的屬性、被修補的內建物件以及模組級狀態都會消失), - 清除 ESM 和 CommonJS 模組登錄檔(每個檔案都會重新求值其匯入內容),
- 關閉檔案留下的伺服器、套接字、檔案監視器和子行程,取消其計時器,並恢復 fake timers,
- 在新的全域性物件中重新執行
--preload指令碼
Jest 和 Vitest 預設會隔離每個檔案。這樣可以消除“單獨執行環境通過、在完整測試套件中失敗”的問題,代價是每個檔案都要重新求值匯入內容。
為了儘可能降低這項開銷,Bun 會在行程級別快取轉譯後的原始碼和位元組碼,並在多個全域性物件之間共享這些快取。第二個匯入某個模組的檔案無需再讀取、轉譯和解析該模組,可以直接進入求值階段。只有模組頂層程式碼會再次執行。
不使用 --isolate 時(預設情況),所有檔案共享一個全域性物件和一個模組登錄檔。這是最快的模式,適用於檔案之間不會洩漏狀態的測試套件
檔案內的並行測試
--parallel 會將 檔案 分散到各個核心上。在一個檔案內,測試仍然會逐個執行,除非選擇啟用並行;啟用後,某個 async 測試等待 I/O 時,其他測試可以與其重疊執行:
import { test, expect } from "bun:test";
test.concurrent("GET /users", async () => {
const res = await fetch(`${baseUrl}/users`);
expect(res.status).toBe(200);
});
test.concurrent("GET /posts", async () => {
const res = await fetch(`${baseUrl}/posts`);
expect(res.status).toBe(200);
});
// runs after the concurrent group, alone
test.serial("resets the database", async () => {
await resetDb();
});
test.concurrent(...)/describe.concurrent(...)標記單個測試或整個測試組--concurrent會將每個測試視為並行測試;test.serial可退出並行模式--max-concurrency=N限制同時執行的測試數量(預設值為 20)bunfig.toml中的concurrentTestGlob僅對匹配的檔案啟用該功能
並行測試共享同一個執行緒和全域性物件;這是面向 I/O 密集型測試的協作式並行,而不是額外的 CPU 核心。在並行執行環境,expect.assertions() 和其他每個測試的全域性狀態需要謹慎處理——請參閱並行測試執行
使用 --shard 將測試套件拆分到 CI 機器
bun test --shard=1/3 # machine 1
bun test --shard=2/3 # machine 2
bun test --shard=3/3 # machine 3
每臺機器都會按路徑對發現的測試檔案排序,並獲取一個確定性的分片,因此所有分片合起來會在無需協調的情況下恰好覆蓋每個檔案一次。不使用 --timings 時,排序列表中的第 i 個檔案會分配給分片 (i mod n) + 1——按檔案數量平衡,而不是按檔案耗時平衡
使用 --timings 進行平衡
檔案數量並不能很好地代表耗時:某個分片可能會分到所有耗時較長的整合測試。為 bun test 提供每個檔案的耗時記錄後,它會改為按總耗時切分分片,並將相鄰檔案(它們共享匯入內容)放在一起:
# Record durations (any run can do this; --parallel is fine)
bun test --timings=.bun-test-timings.json --update-timings
# Use them
bun test --shard=2/8 --parallel --timings=.bun-test-timings.json
該檔案是普通 JSON,按耗時從高到低排列,因此也可以作為“哪些內容較慢”的報告:
{
"version": 1,
"files": {
"test/integration/build.test.ts": 41234,
"test/db/migrate.test.ts": 9876,
"src/router.test.ts": 112
}
}
- 路徑相對於專案根目錄;值表示整個檔案的牆鍾耗時,單位為毫秒。
- 不使用
--shard時,--update-timings會將結果合併到讀取的內容中,因此在本地重新執行部分測試套件會重新整理相應條目並保留其餘條目。已不存在檔案的條目會保留;刪除該檔案即可重新開始。 - 使用
--shard時,--update-timings只會寫入該分片執行過的檔案——見下文。 - 切分分片時,Bun 會假設沒有條目的檔案耗時為中位數;使用
--parallel時,則會先啟動這些檔案。 - 使用
--timings時,--parallel也會使用這些耗時:協調器會按耗時切分工作行程檔案塊,每個工作行程會先啟動耗時最長的檔案。
每個分片一個 timings 檔案
可以多次傳入 --timings。Bun 會將這些檔案作為一個表讀取,並跳過尚不存在的路徑。--update-timings 會寫入到第一個路徑。使用 --shard 時,該輸出只包含該分片執行過的檔案,因此各分片的輸出互不重疊。下次執行環境將這些檔案一起讀取,它們就能組成完整的測試套件,無需合併步驟。主頁面上的大型程式碼庫包含完整的 CI 工作流。
對比
2,000 個 TypeScript 測試檔案 × 每個檔案 8 個小型測試,所有檔案都匯入一個基於 zod、date-fns 和 lodash 建置的小型應用,並通過每個 runner 的預載入機制載入一個共享 setup 檔案(自定義 matcher + beforeEach/afterEach)——這就是大型應用單元測試套件的形態(bench/test/app、bun app/setup.ts 2000 20)。16 核 Apple M4 Max:
| Mode | Bun | Vitest 4.1 | Jest 30 (@swc/jest) |
|---|---|---|---|
| 所有核心,每個工作行程一個全域性物件 | bun test --parallel --no-isolate 0.75 s | vitest run --no-isolate 3.7 s | — |
| 所有核心,每個檔案一個全新全域性物件 | bun test --parallel 6.8 s | vitest run 133 s | jest 19.1 s |
| 單個執行緒,一個全域性物件 | bun test 2.4 s | vitest run --no-isolate --no-file-parallelism 8.5 s | —(jest --runInBand,80 s,仍會隔離每個檔案) |
耗時分佈情況:每個檔案都使用全新全域性物件時,每個 runner 都會重新求值 2 000 次匯入內容和 setup 檔案。Bun 會在這些全域性物件之間共享轉譯後的原始碼和位元組碼,因此無需重新解析,但模組求值和 JIT 預熱仍會在每個檔案中重複進行。這些重複工作正是這種情況下一個共享全域性物件(bun test)勝過十六個隔離工作行程,而十六個共享全域性物件(--parallel --no-isolate)又勝過前兩者的原因。