跳到內容

ch38-testing

取得並執行

這個範例可以獨立 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.

Terminal window
npm install
npm test # 14 tests, 5 files, all green
npm run dev # then: curl localhost:8787/probe/runtime

The 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.

FileAPI under test
test/unit.test.tsrunInDurableObject, runDurableObjectAlarm, listDurableObjectIds
test/integration.test.tsexports.default.fetch() (and deprecated SELF)
test/handlers.test.tscreateScheduledController, createMessageBatch, getQueueResult
test/workflow.test.tsintrospectWorkflowInstance, disableSleeps, mockStepResult, mockStepError
test/environment.test.tsthe 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 devvitest run
eval("1 + 1")EvalError: Code generation from strings disallowed for this contextsame
new Function("return 2")()EvalErrorreturns 2
node:fs / tty / v8 / http / perf_hooks export counts106 / 4 / 23 / 21 / 14same

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_namespace
flagAssertions.assertIsEnabled({
enableFlag: "export_commonjs_default",
disableFlag: "export_commonjs_namespace",
defaultOnDate: "2022-10-31",
});
// your explicit no_nodejs_compat_v2 is REMOVED
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");

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:35

Use 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() sets ackAll: true and leaves explicitAcks empty. Asserting on explicitAcks asserts which API the handler used, not whether the messages were acknowledged.
  • outcome is a constant. Asserting on it proves nothing.
  • m.retry({ delaySeconds: 30 }) surfaces as { msgId: "fail" }. Miniflare’s queue consumer does read r.delaySeconds and response.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 introspectorOn the modifier (inside modify())
waitForStatus, waitForStepResult, getOutput, getError, disposedisableSleeps, mockStepResult, mockStepError, mockEvent

await using matters — the introspector needs disposing.

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.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.ts

tsconfig.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"]
}