bun-test-cloudflare
v0.0.12
Published
Run Cloudflare Workers tests in Bun
Maintainers
Readme
bun-test-cloudflare
Bun test support and a typed harness wrapper for Cloudflare Workers projects, with runtime compatibility patches for running Wrangler test servers under Bun.
What It Provides
bun-test-cloudflare/setup: Bun test preload that patches the runtime pieces Wrangler/Miniflare needs under Bun.bun-test-cloudflare:createCloudflareHarness()wrapper that turns named worker config into typed worker handles.
The setup installs only the Bun/Miniflare compatibility patches still needed for the active Bun version. It also provides a minimal cloudflare:workers DurableObject shim for plain Bun module imports.
Version gates use Bun.semver.satisfies with the full bun --revision value, including prerelease identifiers. Bun >=1.4.2 additionally disables worker-threads-fifo and worker-threads-no-timeouts. The FormData override remains enabled to match Miniflare’s Request/Response implementations. Prerelease and invalid versions keep the patches enabled unless explicitly disabled through BUN_TEST_CLOUDFLARE_DISABLED_PATCHES; build metadata does not affect version matching.
Install
bun add -d bun-test-cloudflareWrangler Compatibility
bun-test-cloudflare requires wrangler >= 4.104.0.
Configure Bun
Preload the setup before app-specific test setup:
[test]
preload = ["bun-test-cloudflare/setup", "./src/tests/setup.ts"]If you do not need app-specific setup, use only:
[test]
preload = ["bun-test-cloudflare/setup"]Create A Typed Harness
Create one test harness module for your package and export the configured harness:
// src/tests/harness.ts
import { createCloudflareHarness, typeToken } from "bun-test-cloudflare";
import path from "node:path";
type BackendBindings = {
IMAGES_BUCKET: R2Bucket;
};
const packageRoot = path.resolve(import.meta.dir, "../..");
export const harness = createCloudflareHarness({
workers: {
BACKEND: {
bindings: typeToken<BackendBindings>(),
configPath: path.join(packageRoot, "wrangler.toml"),
name: "my-backend-worker",
vars: {
APP_ENV: "test",
},
},
CMS: {
configPath: path.join(packageRoot, "../cms/wrangler.toml"),
name: "my-cms-worker",
secrets: {
PAYLOAD_SECRET: "test",
},
},
},
});
export type TestWorkers = Parameters<Parameters<typeof harness.run>[0]>[0];The object keys become the typed worker handles passed to run().
The optional bindings token is type-only metadata for worker.getEnv() and is not passed to Wrangler.
When a worker uses configPath, bun-test-cloudflare reads that Wrangler config, injects
define["process.env.NODE_ENV"] = "'test'", and runs wrangler deploy --dry-run --outdir
once for that harness. Build output is written to
node_modules/.btcf/worker-build/<worker-name>/worker.js. Test runs then use that script
with no_bundle = true, so Wrangler does not rerun its esbuild bundle step for every
run().
Use In Tests
import { expect, test } from "bun:test";
import { harness } from "./harness";
test("calls the backend worker", async () => {
await harness.run(async (workers) => {
const response = await workers.BACKEND.fetch("https://example.com/health");
expect(response.status).toBe(200);
});
});run() leases a prewarmed Wrangler test server, resets its persistent storage before reuse, and returns it to the pool after the callback. The pool stays alive until Bun's suite cleanup. Set BUN_TEST_CLOUDFLARE_DISABLE_SERVER_PREWARM=1 to create and close a server for every run().
Isolated Worker slots
For harnesses that use supported storage, value, Images, internal service, and Durable Object bindings, isolatedWorkerSlots can amortize one workerd startup across multiple test runs while preserving Worker global and storage isolation:
const harness = createCloudflareHarness({
workers: {
BACKEND: { configPath: "wrangler.toml" },
},
});Eligible harnesses use four isolated slots by default. Each slot is a distinct Worker service with namespaced Cache API access and unique D1, KV, and R2 storage identities. A slot is leased once. After every slot in a workerd generation has been consumed, the harness rebuilds that generation in the background. Harnesses with unsupported bindings automatically use the single-slot reset path; explicitly requesting multiple slots with unsupported bindings throws.
prewarmedWorkerdPoolSize defaults to 1, avoiding duplicate startup and memory costs. Set it to 2 or higher only when tests need concurrent harness leases; another ready pool member can then serve leases while a generation rebuilds.
Use the workers handles, server.fetch(), or server.getWorker() supplied to run() so requests follow the active slot. A URL returned by server.listen() addresses the generation's first physical Worker and should not be used with slotted harnesses.
Profile Harness Time
Set BUN_TEST_CLOUDFLARE_TIMINGS=1 to print phase timings for Worker startup, lease acquisition, callback execution, storage reset, and cleanup. Fixture tests also forward their captured Worker timing logs in this mode.
BUN_TEST_CLOUDFLARE_TIMINGS=1 bun testBinding Fixture Coverage
The binding fixture exercises locally simulated value and secret bindings, Analytics Engine, Assets, D1, Durable Objects, Email, Hyperdrive, KV, Queues, R2, Rate Limiting, service bindings, version metadata, and Workflows through createCloudflareHarness(). Browser Rendering, Images, Cache API, and Wasm have dedicated fixtures because they require specialized lifecycle or payload coverage.
The same suite catalogs every binding kind emitted by Wrangler's config converter and asserts whether isolated Worker slots may use it. Binding kinds without a deterministic local simulator are configuration-tested and must fall back to the single-slot reset path. When Wrangler adds a new kind, the catalog test fails until its isolation behavior is explicitly classified.
OpenNext Applications
Build the Next application with opennextjs-cloudflare build before creating the
harness, then use the application's normal wrangler.toml. Relative worker and
asset paths, including .open-next/assets, are resolved from that config file.
import { createCloudflareHarness } from "bun-test-cloudflare";
import path from "node:path";
const appRoot = path.resolve(import.meta.dir, "..");
const harness = createCloudflareHarness({
workers: {
APP: {
configPath: path.join(appRoot, "wrangler.toml"),
name: "my-opennext-app",
},
},
});
await harness.run(async (workers) => {
const response = await workers.APP.fetch("https://example.test/");
expect(await response.text()).toContain("Expected rendered content");
});Lifecycle Events
Use events.beforeRun for per-run setup after Wrangler has started and before the test callback runs:
const harness = createCloudflareHarness({
events: {
beforeRun: async (workers) => {
const env = await workers.BACKEND.getEnv();
await env.DB.prepare("SELECT 1").run();
},
},
workers: {
BACKEND: { configPath: "./wrangler.toml" },
},
});Worker Names
createCloudflareHarness() uses each worker config's name when calling Wrangler's server.getWorker(name). If name is omitted, it falls back to the object key:
const harness = createCloudflareHarness({
workers: {
BACKEND: { configPath: "./wrangler.toml" },
},
});
await harness.run(async (workers) => {
await workers.BACKEND.fetch("https://example.com");
});Direct Server Access
The current Wrangler server is available inside run() when needed:
await harness.run(async (workers, server) => {
const logs = server.getLogs();
const env = await workers.BACKEND.getEnv();
await env.IMAGES_BUCKET.put("fixture.png", new Uint8Array());
});Access The Active Run Context
Code called inside harness.run() can read the active workers and server without threading them through every helper:
import { getCloudflareHarnessRunContext } from "bun-test-cloudflare";
export async function createFixture() {
const { workers } = getCloudflareHarnessRunContext<{
BACKEND: { configPath: string; name: string };
}>();
const env = await workers.BACKEND.getEnv();
await env.MY_BUCKET.put("fixture.txt", "hello");
}The run context is backed by AsyncLocalStorage, so it is scoped to the current harness.run() callback and async work started from it. Calling getCloudflareHarnessRunContext() outside harness.run() throws.
