跳到內容

測試:以及那些本機測不出來的東西

查證日期
驗證環境@cloudflare/vitest-pool-workers@0.20.1·vitest@4.1.10·wrangler@4.118.0·compatibility_date: 2026-07-24

Part 8 開始。前面 37 章累積了一件事:每一章都有一段「本機測不出來」。 這一章要先把能測的建起來,再誠實面對測不到的那一半。


@cloudflare/vitest-pool-workersv0.13.0 做了破壞性重寫。網路上 2025 年的所有設定範例都不能用了。 實測本章寫作時的版本是 0.20.1

三件必須知道的:

需要 vitest@^4.1.0這是 peer dependency 寫死的
Vitest 3.x 會直接 throw不是警告,是 throw new Error
設定形式從 pool 變成 Vite plugindefineWorkersConfig / 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 版都可能弄壞你的測試。釘死版本。

// ❌ 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

envexports 的官方寫法:

import { env, exports } from "cloudflare:workers";
const res = await exports.default.fetch(new Request("https://example.com/"));

⚠️ exports 不像舊的 SELF 那樣暴露 Assets。 要測靜態資源得另外用 startDevWorker()。這是官方 test-apis 頁面唯一還提到 SELF 的地方。


這是設定裡最容易卡住的一步,因為它橫跨 Node 與 Worker 兩個執行環境

函式跑在哪從哪 import
readD1Migrations()Node(設定檔)@cloudflare/vitest-pool-workers
applyD1Migrations()Worker(setup file)cloudflare:test

所以 migration 內容必須從 Node 側「送」進 Worker 側。

⚠️ 型別定義裡的 JSDoc 是過時的。 applyD1Migrations 的註解寫著「Call the readD1Migrations() function from the @cloudflare/vitest-pool-workers/config package」—— 那個 package 子路徑已經不存在。實測要從套件根目錄 import。這是本系列第 N 次遇到「型別註解比實作舊」(第 15、21、23 章)。

舊寫法是 poolOptions.workers.defines。新的 plugin 選項 schema 沒有 defines,而且因為 schema 是 z.strip你傳了它會被安靜丟掉

ReferenceError: __MIGRATIONS__ is not defined
❯ test/apply-migrations.ts:7:35

沒有任何「未知選項」的警告。 正確做法是用 Vite 自己的 define

vitest.config.ts
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"] },
});
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(實測型別檔):listDurableObjectIdsevictDurableObjectevictAllDurableObjectsabortAllDurableObjectsreset

⚠️ 官方 known issues 有一條要記著:WebSocket + Durable Object 與 per-file 隔離不相容。第 16 章的 hibernation 測試會踩到。


scheduledqueue 沒有 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.delaySecondsresponse.retryBatch.delaySeconds。也就是說:這條路徑在測試工具層被截斷了,你的 backoff 策略在這裡結構性地測不出來。 第 19 章講的那些重試延遲設計,只能靠 code review 保證。


第 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,這裡直接 throw
flagAssertions.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;

兩件事:

  1. 你明確設的 no_nodejs_compat_v2 會被直接刪掉。 如果你 production 是刻意不要 v2,測試環境還是會給你 v2。
  2. export_commonjs_default 官方文件完全沒提,但它是硬性斷言。

範例裡有同一段 probe 分別跑在兩邊(GET /probe/runtime vs test/environment.test.ts):

wrangler devvitest run
eval("1 + 1")EvalError: Code generation from strings disallowed同左 ✅
new Function("return 2")()EvalError回傳 2 🔴
node:fs / tty / v8 / http / perf_hooks106 / 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。


isolatedStorage / singleWorker 兩個選項都沒了,行為變成固定的:

  • 儲存隔離是 per test file 的,而且不可設定。
  • 一個測試檔寫進去的東西,另一個測試檔看不到
  • 測試檔預設併發執行。
  • 要共用儲存,只能用 CLI:vitest run --max-workers=1 --no-isolate

搭配官方 known issues 裡幾條實務上真的會咬人的:

  • 一定要 await storage 的 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 裡的 dynamic import() 會失敗。
  • 用 virtual module 或 wildcard re-export 時 ctx.exports 會少東西 —— 用 additionalExports 補。

38.9 本章的重點:把「測不出來的」列成清單

Section titled “38.9 本章的重點:把「測不出來的」列成清單”

這是本系列走到這裡最有價值的一張表。每一條都是前面章節實測出來的,而且沒有一條可以靠 vitest-pool-workers 解決

本機/測試環境測不出來的東西只能怎麼辦
6Cache API 是 stub,match() 永遠 missstaging 上驗
19queue 重試延遲:getQueueResultdelaySeconds 丟掉code review + staging
21Workflow 沒有真正的 replay / 引擎不耐壓只驗邏輯,不驗引擎
22Analytics Engine 寫入不做任何驗證部署後查資料
23Pipelines 不持久化staging
27PBKDF2 迭代上限、CPU 時間限制都不套用只能上線量
28Hyperdrive 的查詢快取不存在staging
29container 資源限制不套用staging
30 / 34 / 35 / 36根本沒有本機 binding(Browser / AI / Gateway / Vectorize)需要真帳號
33cpuMs 限制不強制staging
37Miniflare 的 DO wrapper 讓函式庫自己的 assertion 失效只有部署才會發現
38fake timers 對 KV/R2/cache TTL 無效staging
38new Function() 在測試裡能跑、在 dev 與 production 不能smoke test

共通的處理原則有三條:

  1. 凡是「本機比 production 寬鬆」的,一律用 smoke test 補。 第 40 章的 pipeline 會在部署 staging 之後打一組真請求。
  2. 凡是「本機比 production 嚴格」的(很少,但第 37 章那條是反過來的),寫在 README 裡。 沒有自動化辦法。
  3. 凡是「本機根本沒有」的,把那層抽象成介面,測 fake、在 staging 測真的。 第 34 到 36 章的範例都是這樣寫的。

用什麼測什麼
unitrunInDurableObject / runDurableObjectAlarmDO 內部狀態機、alarm 慣例、SQL
integrationexports.default.fetch()路由、狀態碼、快取有沒有被寫進去
handlercreateScheduledController / createMessageBatch + getQueueResultcron 邏輯、批次聚合、ack/retry
workflowintrospectWorkflowInstance + disableSleeps / mockStepResult步驟順序、輸出、失敗路徑
environment同一段 probe 跑在兩邊記錄測試環境與 dev 的差異

範例裡 14 個測試全綠,tsc --noEmit 乾淨。跑法:

Terminal window
cd examples/ch38-testing
npm install
npm test # vitest run
npm run dev # 然後 curl localhost:8787/probe/runtime 比對

#結論影響
1實測 @cloudflare/vitest-pool-workers 0.20.1vitest 4.1.10v0.13.0 是破壞性重寫,2025 年的範例全失效
2vitest 3.x 直接 throw;其他不合版本只 warn3.x 是硬牆
3警告文字明說「depends on internal Vitest APIs not protected by semver」vitest 的 patch 版都可能弄壞你
4defineWorkersConfig / defineWorkersProject 已移除;改成 cloudflareTest() Vite pluginimport 路徑是套件根目錄
5@cloudflare/vitest-pool-workers/config 子路徑不存在ERR_PACKAGE_PATH_NOT_EXPORTED但型別檔的 JSDoc 還在叫你從那裡 import
6🔴 官方文件說 SELF / env 已「移除」,實測是 @deprecated 仍可用遷移可以漸進,但沒有編譯錯誤提醒你
7真的移除的是 fetchMockisolatedStoragesingleWorker後兩者改成固定行為 + CLI flag
8plugin 選項 schema 是 z.strip —— 多傳的 key 安靜丟掉沒有「未知選項」警告
9🔴 舊的 poolOptions.workers.defines 沒有對應選項,要改用 Vite 的 define症狀是 ReferenceError: __MIGRATIONS__ is not defined
10schema 實際有 remoteBindings / verbose / additionalExports,文件沒列
11readD1Migrations 跑 Node 側、applyD1Migrations 跑 Worker 側migration 必須跨環境傳遞
12runDurableObjectAlarm()boolean,第二次呼叫回 false可以直接斷言「alarm 已消耗」
13🔴 getQueueResult()outcome寫死的 "ok"斷言它毫無意義
14🔴 ackAll()ackAll: trueexplicitAcks空陣列斷言 explicitAcks 等於斷言「用了哪個 API」
15🔴 retry({ delaySeconds })delaySeconds 被丟掉retryMessages 只有 { msgId }backoff 策略結構性測不出來(第 19 章)
16Workflow API 分兩層:waitForStatus/getOutput 在 introspector,disableSleeps/mockStep* 在 modifier最容易搞混的地方
17🔴 mockStepError() 會被 step.do 的預設重試吃掉,測試直接 timeoutretries: { limit: 0 } 才測得到失敗路徑
18推論:沒寫 retry 政策的 step 就是不可測的 step可測性 = 有沒有明確 retry 設定
19注入的 flag 清單遠比文件長;no_nodejs_compat_v2 會被主動刪掉production 刻意不要 v2 的話測試環境不一致
20export_commonjs_default斷言(設了 namespace 就 throw),官方文件沒提
21🔴 實測差異:new Function("return 2")()vitest run2,在 wrangler devEvalErrorpool 的 unsafeEvalBinding 造成
22eval() 兩邊一致 throw;node builtin 的 export 數兩邊一致差異只在 new Function
23儲存隔離固定 per-test-file,不可設定;要共用只能 --max-workers=1 --no-isolate
24fake timers 對 KV / R2 / cache 模擬器無效TTL 過期測不了
25coverage 不支援 V8 native provider,要用 Istanbul
26WebSocket + DO 與 per-file 隔離不相容(官方 known issue)第 16 章的測試會踩到
27exports 不暴露 AssetsSELF 舊行為有)測靜態資源要 startDevWorker()

  1. vitest 降到 3.x,確認它是 throw 不是 warn,記下完整訊息。
  2. cloudflareTest() 裡傳一個不存在的選項(例如 defines),確認完全沒有任何警告。這是 z.strip 的代價。
  3. 寫一個 queue consumer 用 m.retry({ delaySeconds: 300 }),用 getQueueResult() 確認 delaySeconds 真的不見了。然後想想你要怎麼保證 backoff 是對的。
  4. mockStepError() 那個測試裡的 retries: { limit: 0 } 拿掉,確認它 timeout,並算一下預設退避要多久才會到 errored
  5. 同一段 new Function() 分別在 vitest runwrangler dev 跑,把兩個結果並排貼進你的專案 README —— 這是你的團隊最需要知道的一條。
  6. 開兩個測試檔,A 寫 KV、B 讀同一個 key,確認 B 讀不到。再加 --max-workers=1 --no-isolate 重跑。

下一章:第 39 章 Observability —— logs、traces 與 tail。這一章列出的「只能上 staging 驗」的東西,下一章要處理的是「上了 staging 之後怎麼看得到」。