測試:以及那些本機測不出來的東西
Part 8 開始。前面 37 章累積了一件事:每一章都有一段「本機測不出來」。 這一章要先把能測的建起來,再誠實面對測不到的那一半。
38.1 先把版本講清楚
Section titled “38.1 先把版本講清楚”@cloudflare/vitest-pool-workers 在 v0.13.0 做了破壞性重寫。網路上 2025 年的所有設定範例都不能用了。 實測本章寫作時的版本是 0.20.1。
三件必須知道的:
需要 vitest@^4.1.0 | 這是 peer dependency 寫死的 |
| Vitest 3.x 會直接 throw | 不是警告,是 throw new Error |
| 設定形式從 pool 變成 Vite plugin | defineWorkersConfig / defineWorkersProject 已經不存在 |
版本檢查的實作我讀過了(assertCompatibleVitestVersion),它分兩級:
if (satisfies(actualVitestVersion, "3.x")) { throw new Error(`You're running \`vitest@${actual}\`, but this version of \`@cloudflare/vitest-pool-workers\` only supports \`vitest ${expected}\`.`);}if (!satisfies(actualVitestVersion, expectedVitestVersion)) { log.warn(`...only officially supports... \`@cloudflare/vitest-pool-workers\` currently depends on internal Vitest APIs that are not protected by semantic-versioning guarantees.`);}3.x 是硬錯誤,其他不相容版本只是警告。 那句「depends on internal Vitest APIs that are not protected by semantic-versioning guarantees」值得記著 —— 這代表 vitest 的 patch 版都可能弄壞你的測試。釘死版本。
新舊設定對照
Section titled “新舊設定對照”// ❌ v0.12 及之前(現在完全不能用)import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config";export default defineWorkersProject({ test: { poolOptions: { workers: { wrangler: { configPath: "./wrangler.jsonc" } } } },});// ✅ v0.13+import { defineConfig } from "vitest/config";import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
export default defineConfig({ plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],});注意 import 路徑是套件根目錄,不是 /config 子路徑。實測 @cloudflare/vitest-pool-workers/config 會回 ERR_PACKAGE_PATH_NOT_EXPORTED —— 那個子路徑已經不存在了。
官方有 codemod:
@cloudflare/vitest-pool-workers/codemods/vitest-v3-to-v4。它處理設定檔,測試檔要自己改。
實測 plugin 接受的選項(zod schema)比文件寫的多:
{ main?: string; remoteBindings?: boolean; // 文件沒提 verbose?: boolean; // 文件沒提 additionalExports?: Record<string, "WorkerEntrypoint" | "DurableObject" | "WorkflowEntrypoint">; miniflare?: SourcelessWorkerOptions & { workers?: WorkerOptions[] }; wrangler?: { configPath?: string; environment?: string };}而且 schema 是 z.strip —— 你多傳的 key 會被安靜丟掉,不會報錯。 這在下一節會咬人。
38.2 🔴 官方文件說「移除」,實測是「棄用」
Section titled “38.2 🔴 官方文件說「移除」,實測是「棄用」”官方文件(vitest-integration/test-apis/、migration guide)現在說 SELF 已經被 exports.default 取代、env 已經搬到 cloudflare:workers。
實測不是移除,是 @deprecated。 types/cloudflare-test.d.ts 開頭:
declare module "cloudflare:test" { /** @deprecated Instead, use `import { env } from "cloudflare:workers"` */ export const env: Cloudflare.Env;
/** * Service binding to the default export defined in the `main` worker. ... * @deprecated Instead, use `import { exports } from "cloudflare:workers"` and `exports.default.fetch()` */ export const SELF: Fetcher;範例裡有一個測試專門記錄這件事:
// 這個測試會過。它存在是為了記錄事實,不是推薦你這樣寫。it("SELF still works (deprecated)", async () => { const res = await SELF.fetch("https://example.com/"); expect(res.status).toBe(200);});這件事對遷移的影響很大:你不用一次改完。可以先換設定檔(那個是硬性的),測試檔裡的 SELF / env 慢慢換。但也代表你不會收到任何編譯錯誤來提醒你還沒換完 —— 要靠 lint 規則自己擋。
真正被移除(不是棄用)的是三個:
| 舊東西 | 現在 |
|---|---|
fetchMock | 沒了。 自己 mock globalThis.fetch,或用 MSW |
isolatedStorage 選項 | 沒了。 隔離改成 per-test-file,不可設定 |
singleWorker 選項 | 沒了。 改用 CLI 的 --max-workers=1 --no-isolate |
env 與 exports 的官方寫法:
import { env, exports } from "cloudflare:workers";const res = await exports.default.fetch(new Request("https://example.com/"));⚠️
exports不像舊的SELF那樣暴露 Assets。 要測靜態資源得另外用startDevWorker()。這是官方 test-apis 頁面唯一還提到SELF的地方。
38.3 D1 migration:兩邊各跑一半
Section titled “38.3 D1 migration:兩邊各跑一半”這是設定裡最容易卡住的一步,因為它橫跨 Node 與 Worker 兩個執行環境:
| 函式 | 跑在哪 | 從哪 import |
|---|---|---|
readD1Migrations() | Node(設定檔) | @cloudflare/vitest-pool-workers |
applyD1Migrations() | Worker(setup file) | cloudflare:test |
所以 migration 內容必須從 Node 側「送」進 Worker 側。
⚠️ 型別定義裡的 JSDoc 是過時的。
applyD1Migrations的註解寫著「Call thereadD1Migrations()function from the@cloudflare/vitest-pool-workers/configpackage」—— 那個 package 子路徑已經不存在。實測要從套件根目錄 import。這是本系列第 N 次遇到「型別註解比實作舊」(第 15、21、23 章)。
🔴 而且送過去的管道也換了
Section titled “🔴 而且送過去的管道也換了”舊寫法是 poolOptions.workers.defines。新的 plugin 選項 schema 沒有 defines,而且因為 schema 是 z.strip,你傳了它會被安靜丟掉:
ReferenceError: __MIGRATIONS__ is not defined ❯ test/apply-migrations.ts:7:35沒有任何「未知選項」的警告。 正確做法是用 Vite 自己的 define:
import { defineConfig } from "vitest/config";import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";import path from "node:path";
const migrations = await readD1Migrations(path.join(import.meta.dirname, "migrations"));
export default defineConfig({ plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })], // plugin 的選項 schema 不接受 defines(z.strip 會安靜丟掉),用 Vite 的 define define: { __MIGRATIONS__: JSON.stringify(migrations) }, test: { setupFiles: ["./test/apply-migrations.ts"] },});import { applyD1Migrations, env } from "cloudflare:test";import { beforeAll } from "vitest";
declare const __MIGRATIONS__: { name: string; queries: string[] }[];
beforeAll(async () => { await applyD1Migrations(env.DB, __MIGRATIONS__);});第 26 章那個 drizzle-kit v1 資料夾式 migration 佈局的問題在這裡一樣會發生 ——
readD1Migrations()讀的是扁平的.sql檔。
38.4 unit 層:進到 Durable Object 裡面
Section titled “38.4 unit 層:進到 Durable Object 裡面”runInDurableObject() 把你的 callback 送進 DO 的 context 執行,拿得到 instance 本身和 DurableObjectState。這是唯一能直接斷言 DO 內部狀態的方式。
it("bumps and arms an alarm exactly once", async () => { const stub = env.COUNTER.get(env.COUNTER.idFromName("unit-a"));
await runInDurableObject(stub, async (instance, state) => { expect(await state.storage.getAlarm()).toBe(null); expect(await instance.bump()).toBe(1); const first = await state.storage.getAlarm(); expect(first).not.toBe(null);
expect(await instance.bump(4)).toBe(5); // 第二次 bump 不可以重設 alarm expect(await state.storage.getAlarm()).toBe(first); });});這正是第 17 章那個「先檢查 getAlarm() 再 setAlarm()」慣例的測試。 沒有這個 API,你只能從外部行為間接推測。
alarm 本身用 runDurableObjectAlarm() 手動觸發,回傳 boolean 代表「有沒有 alarm 可跑」:
expect(await runDurableObjectAlarm(stub)).toBe(true); // 跑了expect(await runDurableObjectAlarm(stub)).toBe(false); // 已經沒有待跑的 alarm不用真的等 60 秒。
其他可用的 DO 測試 API(實測型別檔):listDurableObjectIds、evictDurableObject、evictAllDurableObjects、abortAllDurableObjects、reset。
⚠️ 官方 known issues 有一條要記著:WebSocket + Durable Object 與 per-file 隔離不相容。第 16 章的 hibernation 測試會踩到。
38.5 handler 層:scheduled 與 queue
Section titled “38.5 handler 層:scheduled 與 queue”scheduled 與 queue 沒有 HTTP 入口,只能直接呼叫 handler,並自己造 controller / batch:
const ctrl = createScheduledController({ scheduledTime: new Date(), cron: "0 * * * *" });const ctx = createExecutionContext();await worker.scheduled(ctrl, env, ctx);await waitOnExecutionContext(ctx); // ← 不能省waitOnExecutionContext() 不能省。 沒有它,waitUntil() 裡的工作會活得比測試久,它的失敗會掉到別的測試上,或者根本消失。
🔴 getQueueResult() 的真實形狀,和它看不到的東西
Section titled “🔴 getQueueResult() 的真實形狀,和它看不到的東西”型別只說回傳 FetcherQueueResult。實測(本機跑出來的實際物件):
{ "outcome": "ok", "ackAll": true, "explicitAcks": [], "retryMessages": [], "retryBatch": { "retry": false }}三個坑,全部實測:
1. ackAll() 不會填 explicitAcks。 呼叫 batch.ackAll() 得到的是 ackAll: true 加空的 explicitAcks。如果你斷言 explicitAcks,你斷言的是「handler 用了哪個 API」,不是「訊息有沒有被確認」。
2. outcome 是寫死的 "ok"。 讀原始碼(dist/worker/lib/cloudflare/test-internal.mjs):
return { outcome: "ok", // ← 常數 retryBatch: { retry: batch[kRetryAll] }, ackAll: batch[kAckAll], retryMessages, explicitAcks,};斷言 outcome 沒有任何意義。
3. 🔴 delaySeconds 被整個丟掉。 同一段原始碼:
for (const message of batch.messages) { if (message[kRetry]) retryMessages.push({ msgId: message.id }); // ← 沒有 delaySeconds if (message[kAck]) explicitAcks.push(message.id);}實測:
m.retry({ delaySeconds: 30 });// getQueueResult → retryMessages: [{ msgId: "fail" }] ← 30 不見了而 miniflare 的 queue consumer 實作確實會讀 r.delaySeconds 與 response.retryBatch.delaySeconds。也就是說:這條路徑在測試工具層被截斷了,你的 backoff 策略在這裡結構性地測不出來。 第 19 章講的那些重試延遲設計,只能靠 code review 保證。
38.6 Workflow:不用等三天
Section titled “38.6 Workflow:不用等三天”第 21 章那個「Workflow 可以睡 365 天」在測試裡顯然行不通。introspectWorkflowInstance() 就是為此存在的。
API 分成兩層,這是最容易搞混的地方:
| 在 introspector 上 | 在 modifier 上(modify() 的 callback 裡) |
|---|---|
waitForStatus(status) | disableSleeps(steps?) |
waitForStepResult({ name, index? }) | mockStepResult(step, result) |
getOutput() | mockStepError(step, error, times?) |
getError() | mockEvent({ type, payload }) |
dispose()(用 await using 自動呼叫) |
it("skips a 3-day sleep and mocks the network step", async () => { // 要在 create() 之前 introspect —— instance id 由你決定 await using instance = await introspectWorkflowInstance(env.REPORT, "wf-a-1"); await instance.modify(async (m) => { await m.disableSleeps(); await m.mockStepResult({ name: "fetch-title" }, { status: 200 }); });
await env.REPORT.create({ id: "wf-a-1", params: { slug: "wf-a" } }); await instance.waitForStatus("complete");
expect(await instance.getOutput()).toEqual({ ok: true, slug: "wf-a", clicks: 42, status: 200 });});await using 不是裝飾用的 —— introspector 需要 dispose,否則會留著 hook。
🔴 mockStepError() 會撞上重試政策
Section titled “🔴 mockStepError() 會撞上重試政策”我第一版這樣寫:
await m.mockStepError({ name: "load-link" }, new Error("d1 exploded"));await env.REPORT.create({ id: "wf-b-1", params: { slug: "wf-b" } });await instance.waitForStatus("errored");結果:
Error: Test timed out in 5000ms.原因很簡單但很容易漏:step.do() 預設會重試(指數退避)。 mock 出來的錯誤每次都會發生,於是 workflow 一直在重試,5 秒內到不了 errored。
解法是把要測失敗的那個 step 明確設成不重試:
const row = await step.do( "load-link", { retries: { limit: 0, delay: 0 }, timeout: "10 seconds" }, async () => { /* ... */ },);這反過來是一個設計提示:如果你的 step 沒有明確的 retry 設定,你就沒辦法對它的失敗路徑寫測試。「可測性」在這裡直接等於「有沒有寫 retry 政策」。
38.7 🔴 測試環境不是 production 環境(實測到一個能證明的例子)
Section titled “38.7 🔴 測試環境不是 production 環境(實測到一個能證明的例子)”官方 migration guide 說 flag 是自動注入的:「nodejs_compat_v2 and Node.js module flags are enabled automatically during tests, matching production behavior」。
實測 pool 的原始碼,注入清單比文件寫的長很多:
// export_commonjs_default 是「斷言」不是「注入」——// 你如果設了 export_commonjs_namespace,這裡直接 throwflagAssertions.assertIsEnabled({ enableFlag: "export_commonjs_default", disableFlag: "export_commonjs_namespace", defaultOnDate: "2022-10-31",});
// 如果 node compat 模式不是 v2,就「刪掉」你的 no_nodejs_compat_v2 再塞 v2 進去if (mode !== "v2") { if (hasNoNodejsCompatV2Flag) compatibilityFlags.splice(indexOf("no_nodejs_compat_v2"), 1); compatibilityFlags.push("nodejs_compat_v2");}
if (!compatibilityFlags.includes("unsafe_module")) compatibilityFlags.push("unsafe_module");ensureFeature(compatibilityFlags, "nodejs_tty_module");ensureFeature(compatibilityFlags, "nodejs_fs_module");ensureFeature(compatibilityFlags, "nodejs_http_modules");ensureFeature(compatibilityFlags, "nodejs_perf_hooks_module");ensureFeature(compatibilityFlags, "nodejs_v8_module");ensureFeature(compatibilityFlags, "nodejs_process_v2");
runnerWorker.unsafeEvalBinding = "__VITEST_POOL_WORKERS_UNSAFE_EVAL";runnerWorker.unsafeUseModuleFallbackService = true;兩件事:
- 你明確設的
no_nodejs_compat_v2會被直接刪掉。 如果你 production 是刻意不要 v2,測試環境還是會給你 v2。 export_commonjs_default官方文件完全沒提,但它是硬性斷言。
能量化的差異:new Function()
Section titled “能量化的差異:new Function()”範例裡有同一段 probe 分別跑在兩邊(GET /probe/runtime vs test/environment.test.ts):
wrangler dev | vitest run | |
|---|---|---|
eval("1 + 1") | EvalError: Code generation from strings disallowed | 同左 ✅ |
new Function("return 2")() | EvalError | 回傳 2 🔴 |
node:fs / tty / v8 / http / perf_hooks | 106 / 4 / 23 / 21 / 14 個 export | 同左 ✅ |
eval 兩邊一致,node 模組兩邊一致,但 new Function 只在測試裡能跑。原因就是上面那行 unsafeEvalBinding —— Vitest 要靠它載入測試模組。
這代表:綠燈的測試不保證程式碼能部署。 第 27 章那個「dynamic import 被
EvalError擋下」的坑,如果你只寫測試不跑wrangler dev,就完全不會發現。一般化的建議:CI 裡除了
vitest run,還要有一個真的把 Worker 起起來打一發請求的 smoke step。 第 40 章會把它放進 pipeline。
38.8 隔離與併發
Section titled “38.8 隔離與併發”isolatedStorage / singleWorker 兩個選項都沒了,行為變成固定的:
- 儲存隔離是 per test file 的,而且不可設定。
- 一個測試檔寫進去的東西,另一個測試檔看不到。
- 測試檔預設併發執行。
- 要共用儲存,只能用 CLI:
vitest run --max-workers=1 --no-isolate。
搭配官方 known issues 裡幾條實務上真的會咬人的:
- 一定要
awaitstorage 的 promise。 沒 await 的寫入會跨越隔離邊界,變成隨機失敗。 - RPC 回傳非 primitive 時要用
using(第 18 章的Symbol.dispose)。 fetch()/R2.get()的 body 要完整消耗掉,否則隔離會出問題。- fake timers 對 KV / R2 / cache 模擬器無效。 想測 TTL 過期?測不了。
- coverage 不支援 V8 native provider,要用 Istanbul。
export default {}內部與 DO event handler 裡的 dynamicimport()會失敗。- 用 virtual module 或 wildcard re-export 時
ctx.exports會少東西 —— 用additionalExports補。
38.9 本章的重點:把「測不出來的」列成清單
Section titled “38.9 本章的重點:把「測不出來的」列成清單”這是本系列走到這裡最有價值的一張表。每一條都是前面章節實測出來的,而且沒有一條可以靠 vitest-pool-workers 解決:
| 章 | 本機/測試環境測不出來的東西 | 只能怎麼辦 |
|---|---|---|
| 6 | Cache API 是 stub,match() 永遠 miss | staging 上驗 |
| 19 | queue 重試延遲:getQueueResult 把 delaySeconds 丟掉 | code review + staging |
| 21 | Workflow 沒有真正的 replay / 引擎不耐壓 | 只驗邏輯,不驗引擎 |
| 22 | Analytics Engine 寫入不做任何驗證 | 部署後查資料 |
| 23 | Pipelines 不持久化 | staging |
| 27 | PBKDF2 迭代上限、CPU 時間限制都不套用 | 只能上線量 |
| 28 | Hyperdrive 的查詢快取不存在 | staging |
| 29 | container 資源限制不套用 | staging |
| 30 / 34 / 35 / 36 | 根本沒有本機 binding(Browser / AI / Gateway / Vectorize) | 需要真帳號 |
| 33 | cpuMs 限制不強制 | staging |
| 37 | Miniflare 的 DO wrapper 讓函式庫自己的 assertion 失效 | 只有部署才會發現 |
| 38 | fake timers 對 KV/R2/cache TTL 無效 | staging |
| 38 | new Function() 在測試裡能跑、在 dev 與 production 不能 | smoke test |
共通的處理原則有三條:
- 凡是「本機比 production 寬鬆」的,一律用 smoke test 補。 第 40 章的 pipeline 會在部署 staging 之後打一組真請求。
- 凡是「本機比 production 嚴格」的(很少,但第 37 章那條是反過來的),寫在 README 裡。 沒有自動化辦法。
- 凡是「本機根本沒有」的,把那層抽象成介面,測 fake、在 staging 測真的。 第 34 到 36 章的範例都是這樣寫的。
38.10 LinkForge:三層測試的分工
Section titled “38.10 LinkForge:三層測試的分工”| 層 | 用什麼 | 測什麼 |
|---|---|---|
| unit | runInDurableObject / runDurableObjectAlarm | DO 內部狀態機、alarm 慣例、SQL |
| integration | exports.default.fetch() | 路由、狀態碼、快取有沒有被寫進去 |
| handler | createScheduledController / createMessageBatch + getQueueResult | cron 邏輯、批次聚合、ack/retry |
| workflow | introspectWorkflowInstance + disableSleeps / mockStepResult | 步驟順序、輸出、失敗路徑 |
| environment | 同一段 probe 跑在兩邊 | 記錄測試環境與 dev 的差異 |
範例裡 14 個測試全綠,tsc --noEmit 乾淨。跑法:
cd examples/ch38-testingnpm installnpm test # vitest runnpm run dev # 然後 curl localhost:8787/probe/runtime 比對38.11 本章實測結論彙整
Section titled “38.11 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | 實測 @cloudflare/vitest-pool-workers 0.20.1、vitest 4.1.10 | v0.13.0 是破壞性重寫,2025 年的範例全失效 |
| 2 | vitest 3.x 直接 throw;其他不合版本只 warn | 3.x 是硬牆 |
| 3 | 警告文字明說「depends on internal Vitest APIs not protected by semver」 | vitest 的 patch 版都可能弄壞你 |
| 4 | defineWorkersConfig / defineWorkersProject 已移除;改成 cloudflareTest() Vite plugin | import 路徑是套件根目錄 |
| 5 | @cloudflare/vitest-pool-workers/config 子路徑不存在(ERR_PACKAGE_PATH_NOT_EXPORTED) | 但型別檔的 JSDoc 還在叫你從那裡 import |
| 6 | 🔴 官方文件說 SELF / env 已「移除」,實測是 @deprecated 仍可用 | 遷移可以漸進,但沒有編譯錯誤提醒你 |
| 7 | 真的移除的是 fetchMock、isolatedStorage、singleWorker | 後兩者改成固定行為 + CLI flag |
| 8 | plugin 選項 schema 是 z.strip —— 多傳的 key 安靜丟掉 | 沒有「未知選項」警告 |
| 9 | 🔴 舊的 poolOptions.workers.defines 沒有對應選項,要改用 Vite 的 define | 症狀是 ReferenceError: __MIGRATIONS__ is not defined |
| 10 | schema 實際有 remoteBindings / verbose / additionalExports,文件沒列 | |
| 11 | readD1Migrations 跑 Node 側、applyD1Migrations 跑 Worker 側 | migration 必須跨環境傳遞 |
| 12 | runDurableObjectAlarm() 回 boolean,第二次呼叫回 false | 可以直接斷言「alarm 已消耗」 |
| 13 | 🔴 getQueueResult() 的 outcome 是寫死的 "ok" | 斷言它毫無意義 |
| 14 | 🔴 ackAll() 讓 ackAll: true 但 explicitAcks 為空陣列 | 斷言 explicitAcks 等於斷言「用了哪個 API」 |
| 15 | 🔴 retry({ delaySeconds }) 的 delaySeconds 被丟掉,retryMessages 只有 { msgId } | backoff 策略結構性測不出來(第 19 章) |
| 16 | Workflow API 分兩層:waitForStatus/getOutput 在 introspector,disableSleeps/mockStep* 在 modifier | 最容易搞混的地方 |
| 17 | 🔴 mockStepError() 會被 step.do 的預設重試吃掉,測試直接 timeout | 要 retries: { limit: 0 } 才測得到失敗路徑 |
| 18 | 推論:沒寫 retry 政策的 step 就是不可測的 step | 可測性 = 有沒有明確 retry 設定 |
| 19 | 注入的 flag 清單遠比文件長;no_nodejs_compat_v2 會被主動刪掉 | production 刻意不要 v2 的話測試環境不一致 |
| 20 | export_commonjs_default 是斷言(設了 namespace 就 throw),官方文件沒提 | |
| 21 | 🔴 實測差異:new Function("return 2")() 在 vitest run 回 2,在 wrangler dev 是 EvalError | pool 的 unsafeEvalBinding 造成 |
| 22 | eval() 兩邊一致 throw;node builtin 的 export 數兩邊一致 | 差異只在 new Function |
| 23 | 儲存隔離固定 per-test-file,不可設定;要共用只能 --max-workers=1 --no-isolate | |
| 24 | fake timers 對 KV / R2 / cache 模擬器無效 | TTL 過期測不了 |
| 25 | coverage 不支援 V8 native provider,要用 Istanbul | |
| 26 | WebSocket + DO 與 per-file 隔離不相容(官方 known issue) | 第 16 章的測試會踩到 |
| 27 | exports 不暴露 Assets(SELF 舊行為有) | 測靜態資源要 startDevWorker() |
38.12 動手練習
Section titled “38.12 動手練習”- 把
vitest降到 3.x,確認它是throw不是 warn,記下完整訊息。 - 在
cloudflareTest()裡傳一個不存在的選項(例如defines),確認完全沒有任何警告。這是z.strip的代價。 - 寫一個 queue consumer 用
m.retry({ delaySeconds: 300 }),用getQueueResult()確認delaySeconds真的不見了。然後想想你要怎麼保證 backoff 是對的。 - 把
mockStepError()那個測試裡的retries: { limit: 0 }拿掉,確認它 timeout,並算一下預設退避要多久才會到errored。 - 同一段
new Function()分別在vitest run與wrangler dev跑,把兩個結果並排貼進你的專案 README —— 這是你的團隊最需要知道的一條。 - 開兩個測試檔,A 寫 KV、B 讀同一個 key,確認 B 讀不到。再加
--max-workers=1 --no-isolate重跑。
- Vitest integration · Test APIs · Configuration
- Isolation and concurrency · Known issues
- Migrate from Vitest 3 to Vitest 4
- API 表面與行為來源:
node_modules/@cloudflare/vitest-pool-workers@0.20.1的dist/pool/index.d.mts(WorkersPoolOptionsSchema、cloudflareTest)、dist/pool/index.mjs(assertCompatibleVitestVersion、flag 注入、unsafeEvalBinding)、dist/worker/lib/cloudflare/test-internal.mjs(getQueueResult)、types/cloudflare-test.d.ts - 所有測試輸出與 HTTP 觀測值取自
examples/ch38-testing,2026-08-01
下一章:第 39 章 Observability —— logs、traces 與 tail。這一章列出的「只能上 staging 驗」的東西,下一章要處理的是「上了 staging 之後怎麼看得到」。