ch38-testing
在 GitHub 上檢視·14 個檔案·19.7 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch38-testing
cd ch38-testing
npm install可用指令
npm run dev # wrangler dev
npm run test # vitest run
npm run types # wrangler types --env-interface Env說明
ch38-testing — the Vitest integration, measured
Section titled “ch38-testing — the Vitest integration, measured”Companion example for chapter 38. Measured against
@cloudflare/vitest-pool-workers@0.20.1, vitest@4.1.10, wrangler 4.118.0,
compatibility date 2026-07-24, on 2026-08-01.
npm installnpm test # 14 tests, 5 files, all greennpm run dev # then: curl localhost:8787/probe/runtimeThe worker under test is a link shortener with a D1 table, a KV cache, a Durable Object counter with an alarm, a queue producer/consumer, a cron handler, and a Workflow — one of each so every test API has something real to act on.
Test layers
Section titled “Test layers”| File | API under test |
|---|---|
test/unit.test.ts | runInDurableObject, runDurableObjectAlarm, listDurableObjectIds |
test/integration.test.ts | exports.default.fetch() (and deprecated SELF) |
test/handlers.test.ts | createScheduledController, createMessageBatch, getQueueResult |
test/workflow.test.ts | introspectWorkflowInstance, disableSleeps, mockStepResult, mockStepError |
test/environment.test.ts | the difference between the test runtime and wrangler dev |
Finding 1 — a green test can be undeployable code
Section titled “Finding 1 — a green test can be undeployable code”test/environment.test.ts and GET /probe/runtime run the same probe on
either side.
wrangler dev | vitest run | |
|---|---|---|
eval("1 + 1") | EvalError: Code generation from strings disallowed for this context | same |
new Function("return 2")() | EvalError | returns 2 |
node:fs / tty / v8 / http / perf_hooks export counts | 106 / 4 / 23 / 21 / 14 | same |
The cause is in the pool’s worker setup:
runnerWorker.unsafeEvalBinding = "__VITEST_POOL_WORKERS_UNSAFE_EVAL";runnerWorker.unsafeUseModuleFallbackService = true;Vitest needs unsafe eval to load test modules. A side effect is that
new Function succeeds in tests and fails everywhere else. So vitest run
passing does not prove the code can deploy — CI needs a real smoke request
against a started Worker too.
The same setup code shows the injected flag list is longer than the docs say:
// asserted, not injected -- throws if you set export_commonjs_namespaceflagAssertions.assertIsEnabled({ enableFlag: "export_commonjs_default", disableFlag: "export_commonjs_namespace", defaultOnDate: "2022-10-31",});
// your explicit no_nodejs_compat_v2 is REMOVEDif (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");Finding 2 — the docs say “removed”, the types say “deprecated”
Section titled “Finding 2 — the docs say “removed”, the types say “deprecated””Cloudflare’s current docs describe SELF and env from cloudflare:test as
replaced by exports.default and env from cloudflare:workers. The shipped
types disagree:
declare module "cloudflare:test" { /** @deprecated Instead, use `import { env } from "cloudflare:workers"` */ export const env: Cloudflare.Env; /** @deprecated Instead, use `import { exports } from "cloudflare:workers"` and `exports.default.fetch()` */ export const SELF: Fetcher;test/integration.test.ts has a passing test that calls SELF.fetch(), kept
as a record of the fact, not as a recommendation. Practical consequence: you
can migrate test files gradually — but nothing will fail to compile to remind
you that you haven’t finished.
Genuinely removed: fetchMock, isolatedStorage, singleWorker.
Finding 3 — defines is gone, and the schema swallows it silently
Section titled “Finding 3 — defines is gone, and the schema swallows it silently”The plugin’s options schema is a Zod object with z.strip, so unknown keys
are dropped without a warning. Passing the old defines option produces no
config error — only this, at runtime:
ReferenceError: __MIGRATIONS__ is not defined ❯ test/apply-migrations.ts:7:35Use Vite’s own define instead (see vitest.config.ts).
The schema actually accepts main, remoteBindings, verbose,
additionalExports, miniflare, wrangler. The docs list only three of
those.
Related stale doc: the JSDoc on applyD1Migrations still says to import
readD1Migrations from @cloudflare/vitest-pool-workers/config. That subpath
does not exist — require.resolve gives ERR_PACKAGE_PATH_NOT_EXPORTED. It
is exported from the package root.
Finding 4 — getQueueResult drops retry delays
Section titled “Finding 4 — getQueueResult drops retry delays”Real shape, measured:
{ "outcome": "ok", "ackAll": true, "explicitAcks": [], "retryMessages": [], "retryBatch": { "retry": false } }From dist/worker/lib/cloudflare/test-internal.mjs:
for (const message of batch.messages) { if (message[kRetry]) retryMessages.push({ msgId: message.id }); // no delaySeconds if (message[kAck]) explicitAcks.push(message.id);}return { outcome: "ok", // hardcoded retryBatch: { retry: batch[kRetryAll] }, // no delaySeconds either ackAll: batch[kAckAll], retryMessages, explicitAcks,};Three consequences, all asserted in test/handlers.test.ts:
ackAll()setsackAll: trueand leavesexplicitAcksempty. Asserting onexplicitAcksasserts which API the handler used, not whether the messages were acknowledged.outcomeis a constant. Asserting on it proves nothing.m.retry({ delaySeconds: 30 })surfaces as{ msgId: "fail" }. Miniflare’s queue consumer does readr.delaySecondsandresponse.retryBatch.delaySeconds— the test harness is where the value is lost. Backoff policy is structurally untestable here.
Finding 5 — mockStepError fights the step retry policy
Section titled “Finding 5 — mockStepError fights the step retry policy”First attempt at a failure-path test timed out at 5000ms. step.do() retries
with exponential backoff by default, and the mocked error recurs every time,
so the instance never reaches errored inside the test timeout.
The fix is to give the step an explicit policy:
const row = await step.do( "load-link", { retries: { limit: 0, delay: 0 }, timeout: "10 seconds" }, async () => { /* ... */ },);Which inverts into a design rule: a step with no explicit retry policy has no testable failure path.
Note also that the Workflow API is split across two objects, and mixing them up is the most common mistake:
| On the introspector | On the modifier (inside modify()) |
|---|---|
waitForStatus, waitForStepResult, getOutput, getError, dispose | disableSleeps, mockStepResult, mockStepError, mockEvent |
await using matters — the introspector needs disposing.
Configuration reference
Section titled “Configuration reference”vitest.config.ts:
import { defineConfig } from "vitest/config";import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";import path from "node:path";
// Node side. applyD1Migrations (in the setup file) is the Worker side.const migrations = await readD1Migrations(path.join(import.meta.dirname, "migrations"));
export default defineConfig({ plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })], define: { __MIGRATIONS__: JSON.stringify(migrations) }, test: { setupFiles: ["./test/apply-migrations.ts"] },});Version gate, from assertCompatibleVitestVersion: vitest 3.x throws;
anything else outside the peer range only warns, with the message that the
pool “depends on internal Vitest APIs that are not protected by
semantic-versioning guarantees”. Pin your versions.
原始碼
wrangler.jsoncpackage.jsonsrc/index.tssrc/workflow.tsmigrations/0001_init.sqltest/apply-migrations.tstest/environment.test.tstest/handlers.test.tstest/integration.test.tstest/unit.test.tstest/workflow.test.tsvitest.config.ts.gitignoretsconfig.json
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch38-testing",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
// The test integration injects nodejs_compat / no_nodejs_compat_v2 /
// export_commonjs_default whether you ask for it or not. Declare it here so
// tests and production agree.
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"d1_databases": [
{ "binding": "DB", "database_name": "ch38", "database_id": "ch38-local", "migrations_dir": "migrations" }
],
"kv_namespaces": [{ "binding": "CACHE", "id": "ch38-cache" }],
"durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "LinkCounter" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["LinkCounter"] }],
"queues": {
"producers": [{ "binding": "EVENTS", "queue": "ch38-events" }],
"consumers": [{ "queue": "ch38-events", "max_batch_size": 10 }]
},
"triggers": { "crons": ["0 * * * *"] },
"workflows": [
{ "name": "ch38-report", "binding": "REPORT", "class_name": "ReportWorkflow" }
]
}package.json
{
"name": "ch38-testing",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"test": "vitest run",
"types": "wrangler types --env-interface Env"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.20.1",
"@types/node": "^26.1.2",
"typescript": "^5.9.2",
"vitest": "^4.1.0",
"wrangler": "^4.118.0"
}
}src/index.ts
import { DurableObject } from "cloudflare:workers";
export { ReportWorkflow } from "./workflow";
export type ClickEvent = { slug: string; at: number };
/** A per-slug click counter with an alarm that flushes to D1. */
export class LinkCounter extends DurableObject<Env> {
async bump(n = 1): Promise<number> {
const current = (await this.ctx.storage.get<number>("clicks")) ?? 0;
const next = current + n;
await this.ctx.storage.put("clicks", next);
// Only arm the alarm if one is not already pending (ch17).
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
return next;
}
async clicks(): Promise<number> {
return (await this.ctx.storage.get<number>("clicks")) ?? 0;
}
async alarm(): Promise<void> {
const slug = (await this.ctx.storage.get<string>("slug")) ?? "unknown";
const clicks = await this.clicks();
await this.ctx.storage.put("flushedAt", Date.now());
await this.env.DB.prepare("UPDATE links SET clicks = ?1 WHERE slug = ?2")
.bind(clicks, slug)
.run();
}
async setSlug(slug: string): Promise<void> {
await this.ctx.storage.put("slug", slug);
}
}
async function createLink(env: Env, slug: string, url: string): Promise<void> {
await env.DB.prepare("INSERT INTO links (slug, url, created_at) VALUES (?1, ?2, ?3)")
.bind(slug, url, Date.now())
.run();
}
async function resolveLink(env: Env, slug: string): Promise<string | null> {
const cached = await env.CACHE.get(`link:${slug}`);
if (cached !== null) return cached;
const row = await env.DB.prepare("SELECT url FROM links WHERE slug = ?1")
.bind(slug)
.first<{ url: string }>();
if (row === null) return null;
await env.CACHE.put(`link:${slug}`, row.url, { expirationTtl: 300 });
return row.url;
}
export default {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/links") {
const body = (await req.json()) as { slug?: string; url?: string };
if (!body.slug || !body.url) return new Response("bad request", { status: 400 });
await createLink(env, body.slug, body.url);
return Response.json({ slug: body.slug }, { status: 201 });
}
if (url.pathname.startsWith("/go/")) {
const slug = url.pathname.slice("/go/".length);
const target = await resolveLink(env, slug);
if (target === null) return new Response("not found", { status: 404 });
await env.EVENTS.send({ slug, at: Date.now() } satisfies ClickEvent);
return Response.redirect(target, 302);
}
if (url.pathname.startsWith("/stats/")) {
const slug = url.pathname.slice("/stats/".length);
const stub = env.COUNTER.get(env.COUNTER.idFromName(slug));
return Response.json({ slug, clicks: await stub.clicks() });
}
// Same probe as test/environment.test.ts, so you can diff `wrangler dev`
// against `vitest run`.
if (url.pathname === "/probe/runtime") {
const probe = (fn: () => unknown) => {
try { return { ok: String(fn()) }; }
catch (e) { return { threw: `${(e as Error).name}: ${(e as Error).message}`.slice(0, 90) }; }
};
const out: Record<string, unknown> = {
eval: probe(() => eval("1 + 1")),
newFunction: probe(() => new Function("return 2")()),
};
for (const m of ["node:fs", "node:tty", "node:v8", "node:http", "node:perf_hooks"]) {
try { out[m] = Object.keys(await import(/* @vite-ignore */ m)).length; }
catch (e) { out[m] = `THREW ${(e as Error).name}`; }
}
return Response.json(out);
}
return new Response("ch38-testing", { status: 200 });
},
async queue(batch: MessageBatch<ClickEvent>, env: Env, _ctx: ExecutionContext): Promise<void> {
const bySlug = new Map<string, number>();
for (const m of batch.messages) {
bySlug.set(m.body.slug, (bySlug.get(m.body.slug) ?? 0) + 1);
}
for (const [slug, n] of bySlug) {
const stub = env.COUNTER.get(env.COUNTER.idFromName(slug));
await stub.setSlug(slug);
await stub.bump(n);
}
batch.ackAll();
},
async scheduled(_c: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
// Prune links older than 30 days that were never clicked.
const cutoff = Date.now() - 30 * 86_400_000;
await env.DB.prepare("DELETE FROM links WHERE clicks = 0 AND created_at < ?1")
.bind(cutoff)
.run();
},
} satisfies ExportedHandler<Env, ClickEvent>;src/workflow.ts
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
export type ReportParams = { slug: string };
export class ReportWorkflow extends WorkflowEntrypoint<Env, ReportParams> {
async run(event: WorkflowEvent<ReportParams>, step: WorkflowStep) {
const slug = event.payload.slug;
const row = await step.do(
"load-link",
// Without this, a failing step is retried with exponential backoff and a
// mockStepError() test just times out. See the README.
{ retries: { limit: 0, delay: 0 }, timeout: "10 seconds" },
async () => {
return await this.env.DB.prepare("SELECT url, clicks FROM links WHERE slug = ?1")
.bind(slug)
.first<{ url: string; clicks: number }>();
},
);
if (row === null) return { ok: false as const, reason: "missing" };
// A deliberately long wait, so the test has something to disable.
await step.sleep("cool-off", "3 days");
const enriched = await step.do("fetch-title", async () => {
// In production this hits the network. In tests it is the obvious
// candidate for mockStepResult().
const res = await fetch(row.url);
return { status: res.status };
});
await step.do("persist", async () => {
await this.env.CACHE.put(`report:${slug}`, JSON.stringify({ ...row, ...enriched }));
});
return { ok: true as const, slug, clicks: row.clicks, status: enriched.status };
}
}migrations/0001_init.sql
CREATE TABLE links (
slug TEXT PRIMARY KEY,
url TEXT NOT NULL,
clicks INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX links_created_at ON links (created_at DESC);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__);
});test/environment.test.ts
import { describe, it, expect } from "vitest";
const probe = (fn: () => unknown) => {
try {
return { ok: String(fn()) };
} catch (e) {
return { threw: `${(e as Error).name}` };
}
};
/**
* These tests assert on the DIFFERENCE between the test runtime and
* `wrangler dev`. Hit GET /probe/runtime under `npm run dev` to see the other
* side of each pair.
*/
describe("the test runtime is not the dev runtime", () => {
it("blocks eval() in both", () => {
expect(probe(() => eval("1 + 1"))).toEqual({ threw: "EvalError" });
});
it("ALLOWS new Function() here, but wrangler dev blocks it", () => {
// The pool sets `unsafeEvalBinding` on the runner worker so Vitest can
// load test modules. A side effect is that `new Function` succeeds.
//
// vitest run -> { ok: "2" }
// wrangler dev -> { threw: "EvalError: Code generation from strings
// disallowed for this context" }
//
// So a green test proves nothing about whether this code can deploy.
expect(probe(() => new Function("return 2")())).toEqual({ ok: "2" });
});
it("resolves the same node builtins as wrangler dev (measured, not assumed)", async () => {
const counts: Record<string, number | string> = {};
for (const m of ["node:fs", "node:tty", "node:v8", "node:http", "node:perf_hooks"]) {
try {
counts[m] = Object.keys(await import(/* @vite-ignore */ m)).length;
} catch (e) {
counts[m] = `THREW ${(e as Error).name}`;
}
}
expect(counts).toEqual({
"node:fs": 106,
"node:tty": 4,
"node:v8": 23,
"node:http": 21,
"node:perf_hooks": 14,
});
});
});test/handlers.test.ts
import {
env,
createExecutionContext,
waitOnExecutionContext,
createScheduledController,
createMessageBatch,
getQueueResult,
} from "cloudflare:test";
import { describe, it, expect } from "vitest";
import worker, { type ClickEvent } from "../src/index";
describe("scheduled handler", () => {
it("prunes unclicked links older than 30 days", async () => {
const old = Date.now() - 40 * 86_400_000;
await env.DB.batch([
env.DB.prepare("INSERT INTO links (slug, url, clicks, created_at) VALUES ('sch-old', 'https://a', 0, ?1)").bind(old),
env.DB.prepare("INSERT INTO links (slug, url, clicks, created_at) VALUES ('sch-hot', 'https://b', 3, ?1)").bind(old),
env.DB.prepare("INSERT INTO links (slug, url, clicks, created_at) VALUES ('sch-new', 'https://c', 0, ?1)").bind(Date.now()),
]);
const ctrl = createScheduledController({ scheduledTime: new Date(), cron: "0 * * * *" });
const ctx = createExecutionContext();
await worker.scheduled(ctrl, env, ctx);
// Always await this: without it, waitUntil work can outlive the test and
// its failures land in the wrong test (or nowhere).
await waitOnExecutionContext(ctx);
const { results } = await env.DB.prepare("SELECT slug FROM links WHERE slug LIKE 'sch-%' ORDER BY slug").all();
expect(results.map((r) => r.slug)).toEqual(["sch-hot", "sch-new"]);
});
});
describe("queue consumer", () => {
it("aggregates a batch per slug and forwards to the DO", async () => {
const batch = createMessageBatch<ClickEvent>("ch38-events", [
{ id: "1", timestamp: new Date(), body: { slug: "q-a", at: Date.now() }, attempts: 1 },
{ id: "2", timestamp: new Date(), body: { slug: "q-a", at: Date.now() }, attempts: 1 },
{ id: "3", timestamp: new Date(), body: { slug: "q-b", at: Date.now() }, attempts: 1 },
]);
const ctx = createExecutionContext();
await worker.queue(batch, env, ctx);
await waitOnExecutionContext(ctx);
// getQueueResult reports what the consumer acked/retried -- the thing you
// actually care about and cannot see from the handler's return value.
// The real shape is FetcherQueueResult:
// { outcome, ackAll, explicitAcks[], retryMessages[], retryBatch: { retry } }
// Note ackAll() sets `ackAll: true` and leaves `explicitAcks` EMPTY. If you
// assert on explicitAcks you are asserting on which API the handler used,
// not on whether the messages were acknowledged.
const result = await getQueueResult(batch, ctx);
// `outcome` is a hardcoded "ok" in the harness -- asserting on it proves
// nothing. Assert on the ack/retry fields instead.
expect(result.outcome).toBe("ok");
expect(result.ackAll).toBe(true);
expect(result.explicitAcks).toEqual([]);
expect(result.retryMessages).toEqual([]);
expect(result.retryBatch.retry).toBe(false);
const a = env.COUNTER.get(env.COUNTER.idFromName("q-a"));
const b = env.COUNTER.get(env.COUNTER.idFromName("q-b"));
expect(await a.clicks()).toBe(2);
expect(await b.clicks()).toBe(1);
});
});
describe("queue consumer, per-message ack/retry", () => {
it("distinguishes explicitAcks from retryMessages", async () => {
const batch = createMessageBatch<ClickEvent>("ch38-events", [
{ id: "keep", timestamp: new Date(), body: { slug: "ok", at: Date.now() }, attempts: 1 },
{ id: "fail", timestamp: new Date(), body: { slug: "bad", at: Date.now() }, attempts: 1 },
]);
const ctx = createExecutionContext();
// A hand-written consumer, so the ack/retry decisions are explicit.
for (const m of batch.messages) {
if (m.body.slug === "bad") m.retry({ delaySeconds: 30 });
else m.ack();
}
await waitOnExecutionContext(ctx);
const result = await getQueueResult(batch, ctx);
expect(result.ackAll).toBe(false);
expect(result.explicitAcks).toEqual(["keep"]);
// delaySeconds is DROPPED. getQueueResult builds this array as
// retryMessages.push({ msgId: message.id })
// and never reads the retry options. Same for retryBatch, which is only
// { retry: boolean }. Your backoff policy is structurally untestable here.
expect(result.retryMessages).toEqual([{ msgId: "fail" }]);
expect(result.retryBatch).toEqual({ retry: false });
});
});test/integration.test.ts
import { exports } from "cloudflare:workers";
import { SELF, env } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("HTTP (integration)", () => {
it("creates and resolves a link through the real handler", async () => {
const created = await exports.default.fetch(
new Request("https://example.com/links", {
method: "POST",
body: JSON.stringify({ slug: "int-a", url: "https://cloudflare.com/" }),
}),
);
expect(created.status).toBe(201);
const res = await exports.default.fetch(new Request("https://example.com/go/int-a", { redirect: "manual" }));
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("https://cloudflare.com/");
// The redirect path warms KV; prove the cache was populated.
expect(await env.CACHE.get("link:int-a")).toBe("https://cloudflare.com/");
});
it("404s an unknown slug", async () => {
const res = await exports.default.fetch(new Request("https://example.com/go/nope"));
expect(res.status).toBe(404);
});
// `SELF` is deprecated in favour of `exports.default`, but still exported.
// This test exists to record that it still works, not to recommend it.
it("SELF still works (deprecated)", async () => {
const res = await SELF.fetch("https://example.com/");
expect(res.status).toBe(200);
expect(await res.text()).toBe("ch38-testing");
});
});test/unit.test.ts
import { env, runInDurableObject, runDurableObjectAlarm, listDurableObjectIds } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import type { LinkCounter } from "../src/index";
describe("LinkCounter (unit, inside the DO)", () => {
it("bumps and arms an alarm exactly once", async () => {
const id = env.COUNTER.idFromName("unit-a");
const stub = env.COUNTER.get(id) as DurableObjectStub<LinkCounter>;
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);
// Second bump must NOT re-arm the alarm.
expect(await state.storage.getAlarm()).toBe(first);
});
});
it("runs the alarm handler and writes through to D1", async () => {
await env.DB.prepare("INSERT INTO links (slug, url, created_at) VALUES (?1, ?2, ?3)")
.bind("unit-b", "https://example.com", Date.now())
.run();
const stub = env.COUNTER.get(env.COUNTER.idFromName("unit-b")) as DurableObjectStub<LinkCounter>;
await runInDurableObject(stub, async (instance) => {
await instance.setSlug("unit-b");
await instance.bump(7);
});
// Fires the pending alarm immediately instead of waiting 60s.
expect(await runDurableObjectAlarm(stub)).toBe(true);
// No alarm left, so a second call reports nothing to run.
expect(await runDurableObjectAlarm(stub)).toBe(false);
const row = await env.DB.prepare("SELECT clicks FROM links WHERE slug = ?1")
.bind("unit-b")
.first<{ clicks: number }>();
expect(row?.clicks).toBe(7);
});
it("can enumerate the instances it created", async () => {
const ids = await listDurableObjectIds(env.COUNTER);
expect(ids.length).toBeGreaterThan(0);
});
});test/workflow.test.ts
import { env, introspectWorkflowInstance } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("ReportWorkflow", () => {
it("skips a 3-day sleep and mocks the network step", async () => {
await env.DB.prepare("INSERT INTO links (slug, url, clicks, created_at) VALUES (?1, ?2, ?3, ?4)")
.bind("wf-a", "https://example.com/x", 42, Date.now())
.run();
// Introspect BEFORE create() -- the instance id is chosen by you.
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,
});
expect(await env.CACHE.get("report:wf-a")).toBe(
JSON.stringify({ url: "https://example.com/x", clicks: 42, status: 200 }),
);
});
it("surfaces a step failure", async () => {
await using instance = await introspectWorkflowInstance(env.REPORT, "wf-b-1");
await instance.modify(async (m) => {
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");
});
});vitest.config.ts
import { defineConfig } from "vitest/config";
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";
import path from "node:path";
// readD1Migrations runs on the NODE side (config/setup), not inside the
// Worker. applyD1Migrations runs on the WORKER side. That split is the whole
// reason the migrations have to be handed over through `defines`.
const migrations = await readD1Migrations(path.join(import.meta.dirname, "migrations"));
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" },
}),
],
// The plugin options schema no longer accepts `defines` (it is z.strip, so
// the key is silently dropped). Use Vite's own `define` instead.
define: { __MIGRATIONS__: JSON.stringify(migrations) },
test: {
setupFiles: ["./test/apply-migrations.ts"],
},
});.gitignore
node_modules/
.wrangler/
worker-configuration.d.tstsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler",
"types": ["./worker-configuration.d.ts", "@cloudflare/vitest-pool-workers/types", "node"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "*.ts", "worker-configuration.d.ts"]
}