sdxc

Type to search, or start from one of these:

@sdxc/cloudflare-mocks

In-memory, behavior-accurate Cloudflare binding mocks for tests

npm add @sdxc/cloudflare-mocks
pnpm add @sdxc/cloudflare-mocks
yarn add @sdxc/cloudflare-mocks
bun add @sdxc/cloudflare-mocks

In-memory, behavior-accurate Cloudflare binding mocks for tests.

Storage bindings really store, and SQL bindings really run SQL through the runtime's own SQLite, so a malformed statement or a constraint violation fails in the test rather than in production. Every factory returns an isolated instance typed against the matching platform interface, so a mock that drifts from the platform's shape fails typecheck.

Installation

npm add -D @sdxc/cloudflare-mocks

The binding interfaces come from @cloudflare/workers-types, which installs alongside this package.

Usage

Storage That Really Stores

import { createD1Database, createKVNamespace } from "@sdxc/cloudflare-mocks";

let kv = createKVNamespace();
await kv.put("user:1", JSON.stringify({ name: "Ada" }), { metadata: { version: 2 } });

await kv.get<{ name: string }>("user:1", "json"); // { name: "Ada" }
await kv.list({ prefix: "user:" }); // real prefix filtering, with metadata

let db = createD1Database();
await db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL)");

let result = await db.prepare("INSERT INTO users VALUES (?, ?)").bind(1, "ada@example.com").run();
result.meta.changes; // 1, reported by SQLite

Recording What A Worker Sent

import { createQueue } from "@sdxc/cloudflare-mocks";

let queue = createQueue<{ type: string; id: string }>();
await queue.send({ type: "refresh", id: "abc" });

queue.messages.length; // 1

// Drive the consumer, then assert on what it decided.
await queue.consume(async (batch) => {
	for (let message of batch.messages) message.retry();
});

queue.messages[0]?.attempts; // 1

Assembling An Env

import {
	createD1Database,
	createEnv,
	createKVNamespace,
	createQueue,
} from "@sdxc/cloudflare-mocks";

let env = createEnv<Env>({
	DB: createD1Database(),
	CACHE: createKVNamespace(),
	QUEUE: createQueue(),
});

env.MAILER; // throws: env.MAILER was not provided to createEnv()

Reading a binding that was not supplied throws by name, so a forgotten binding fails at the access that needed it rather than surfacing later as undefined is not a function. Bindings are copied by property descriptor, so a binding defined as a getter is re-read on every access and a test can swap what it resolves to between cases.

Deferred Work

import { createExecutionContext } from "@sdxc/cloudflare-mocks";

let ctx = createExecutionContext();
await handler(request, env, ctx);
await ctx.settled(); // awaits every waitUntil promise, including nested ones

API

Every factory returns isolated state, so calling one in beforeEach removes any need for a cleanup step. When a binding has to live at module scope because the code under test captured env on import, call reset() in beforeEach instead — every stateful factory has one.

createKVNamespace(options?: KVNamespaceMockOptions): KVNamespace

An in-memory Workers KV namespace with real get, put, delete, list, and getWithMetadata semantics: value decoding per type (text, json, arrayBuffer, stream), bulk reads by key array, absolute and TTL expiration, metadata round-tripping, and cursor-paginated prefix listing. Adds reset().

options.now is a clock in milliseconds since the epoch. Because the mock enforces the platform's 60 second expirationTtl floor, an injected clock is the only way to observe expiry without waiting a real minute:

let clock = 0;
let kv = createKVNamespace({ now: () => clock });

await kv.put("key", "value", { expirationTtl: 60 });
clock += 61_000;
await kv.get("key"); // null

createD1Database(options?: D1DatabaseMockOptions): D1Database

A D1Database over a fresh in-memory SQLite database. prepare().bind().all(), run(), first(), and raw() all execute real SQL and report meta from the engine: changes, rows_read, rows_written, last_row_id, changed_db, size_after, and duration. Statements autocommit individually, exactly as D1's do, and batch() is the one atomic primitive — it wraps every statement in a real transaction and rolls the whole batch back on failure.

options.filename is the SQLite file to open, :memory: by default. Adds reset(), which drops every table, index, view, and trigger so a migration can be applied again.

let db = createD1Database();
await db.exec("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)");

await db.prepare("SELCT * FROM posts").all(); // rejects: the typo is real SQL, and it fails

createSqlStorage(options?: SqlStorageMockOptions): SqlStorage

A Durable Object SqlStorage over a fresh in-memory SQLite database. exec runs synchronously and returns a single-pass cursor with toArray, one, next, raw, columnNames, rowsRead, and rowsWritten. options.filename behaves as it does for D1.

let sql = createSqlStorage();
sql.exec("CREATE TABLE counters (name TEXT PRIMARY KEY, value INTEGER)");
sql.exec("INSERT INTO counters VALUES (?, ?)", "hits", 1);

sql.exec("SELECT value FROM counters WHERE name = ?", "hits").one(); // { value: 1 }

BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE and ROLLBACK TO throw the runtime's own error, exactly as a Durable Object rejects them, so code reaching for a SQL transaction fails in the test rather than on deploy:

sql.exec("BEGIN");
// throws: To execute a transaction, please use the state.storage.transaction() or
// state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or
// SAVEPOINT statements. …

A statement is matched on the keyword it opens with, upper or lower case, after any leading whitespace and comments, and every statement of a ;-separated script is checked. The split honors strings, quoted identifiers and comments, so a migration that mentions one of those words inside a literal or a table name runs as written.

ctx.storage.transactionSync(callback) is the atomicity the platform offers in its place, and createDurableObjectState() implements it across both SQL and key-value storage.

MockSqlStorageCursor and MockSqlStorageStatement are exported as well, so a test can assert a cursor's identity when it needs to.

createR2Bucket(): R2BucketMock

An in-memory R2Bucket. Writes compute a real MD5 etag and verify any checksum the caller supplied; reads honor range (offset/length, suffix, or a Range header) and onlyIf, returning the object without a body when a condition fails. list implements prefix, delimiter grouping into delimitedPrefixes, limit, cursor, startAfter, and include. Multipart uploads buffer parts and assemble them in part-number order on complete. Exposes keys and reset() alongside the binding surface.

let bucket = createR2Bucket();
await bucket.put("posts/a.md", "# Hello", { httpMetadata: { contentType: "text/markdown" } });

let object = await bucket.get("posts/a.md", { range: { offset: 0, length: 1 } });
await object?.text(); // "#"

createQueue<Body>(options?: QueueMockOptions): QueueMock<Body>

A Queue that records sends and can drive a consumer. options.name is reported to consumers as batch.queue, options.maxBatchSize is the deliveries per consume() pass (10 by default), and options.maxRetries is the retry budget before a message is dead-lettered (3 by default). Exposes messages (pending), sent (full history), deadLetter, consume(), and reset().

consume(handler, options?) delivers one batch and then applies the handler's decisions: messages the handler neither acked nor retried are acked, and when the handler throws every unacked message is retried and the error is rethrown so the test sees it. It resolves to { delivered, acked, retried, deadLettered }.

let queue = createQueue<{ id: string }>({ maxRetries: 1 });
await queue.send({ id: "a" });

await queue.consume((batch) => batch.retryAll()); // requeued, attempts = 1
let result = await queue.consume((batch) => batch.retryAll());

result.deadLettered; // the message, now past its retry budget

options.context covers the handler that does its real work in waitUntil. Such a handler has decided nothing by the time it returns, so draining that work first is what lets the pass read the ack it eventually makes:

let ctx = createExecutionContext();
let queue = createQueue<{ id: string }>();
await queue.send({ id: "a" });

let result = await queue.consume(
	(batch) => {
		for (let message of batch.messages) ctx.waitUntil(run(message).then(() => message.ack()));
	},
	{ context: ctx },
);

result.acked; // the message, because its deferred work ran first

Anything with a settled(): Promise<void> works, so a Worker calling a module-level waitUntil can pass whatever collects those promises instead of an execution context.

createSendEmail(options?: SendEmailMockOptions): SendEmailMock

A SendEmail binding that records messages instead of delivering them. It accepts both shapes the platform accepts — a raw MIME EmailMessage and the field-based builder — and normalizes them into one SentEmailRecord with recipients flattened to plain addresses. With options.verifiedDestinations set, sending to an address outside the list throws, the way the platform rejects unverified destinations. Exposes messages and reset().

let mailer = createSendEmail({ verifiedDestinations: ["user@example.com"] });

await mailer.send({ from: "noreply@example.com", to: "user@example.com", subject: "Hi" });

mailer.messages[0]?.subject; // "Hi"

createAnalyticsEngine(): AnalyticsEngineMock

An AnalyticsEngineDataset that records every writeDataPoint call, exposing dataPoints (each a detached copy of what was written) and reset(). writeDataPoint is fire-and-forget on the platform, so an over-budget data point is lost silently in production; this mock throws instead. More than 20 blobs, more than 20 doubles, more than one index, blobs over 5 KiB combined, or an index over 96 bytes all fail.

createRateLimit(options?: RateLimitMockOptions): RateLimitMock

A RateLimit binding with real per-key counters over a fixed window. options.limit is the requests allowed per window (100 by default), options.period the window length in seconds, 10 or 60 (60 by default), and options.now a clock in milliseconds so a test can roll the window over. Adds count(key) and reset() for assertions.

let limiter = createRateLimit({ limit: 2 });

await limiter.limit({ key: "ip" }); // { success: true }
await limiter.limit({ key: "ip" }); // { success: true }
await limiter.limit({ key: "ip" }); // { success: false }

createSecretsStoreSecret(options?: SecretsStoreSecretMockOptions): SecretsStoreSecretMock

A SecretsStoreSecret whose answer can be switched between tests. options.name is used in the not-found error, and options.value is what get() resolves with; omitted, the secret reads as missing. Exposes reads, set(), fail(), and reset().

The value is only reachable through an awaited get(), exactly as the platform requires, so code that treats the binding as a string fails here rather than in production. reads is what lets a test prove the secret was read lazily, at its point of use.

let token = createSecretsStoreSecret({ name: "API_TOKEN", value: "sk_live_1" });
let env = createEnv<Env>({ API_TOKEN: token });

await env.API_TOKEN.get(); // "sk_live_1"

token.fail(); // the store cannot answer
await env.API_TOKEN.get(); // rejects: Secret "API_TOKEN" not found

createDurableObjectState<Props>(options?: DurableObjectStateMockOptions<Props>): DurableObjectStateMock<Props>

A DurableObjectState usable directly as a Durable Object constructor argument. options.name is the name the id reports, options.id a hex id string, and options.props the value exposed as state.props. Exposes waitUntilPromises, abortReason, and settled().

storage implements get/put/delete/list/deleteAll with real ordering and bounds, transaction with rollback on throw and on rollback(), transactionSync covering both SQL and key-value writes, alarms, the synchronous storage.kv API over the same store, and a SQL-backed storage.sql. Values are structured-cloned on write and read, so a stored object cannot be mutated through the reference the caller kept. blockConcurrencyWhile serializes overlapping callers.

let state = createDurableObjectState({ name: "tenant-1" });
let object = new Counter(state, env);

await object.increment();
await state.storage.get<number>("count"); // 1

createDurableObjectNamespace<T>(createStub: DurableObjectStubFactory): DurableObjectNamespaceMock<T>

A DurableObjectNamespace that routes names to stubs the caller supplies. createStub builds the object a name routes to: return a handler for a stub that only answers fetch, or an object for one that also exposes RPC methods. Exposes names (distinct, resolved so far), resolutions (every resolution with its placement), and reset().

A name resolves to the same stub every time, which is the property the platform guarantees and the one code under test relies on when it addresses an object by name from more than one place. Ids carry the name they were derived from, so idFromName then get reaches the same object as getByName. Pass the branded Durable Object type as T — usually inferred from the Env the binding is assigned into — to have RPC methods typed on the stub.

let shards = createDurableObjectNamespace((name) => async () => Response.json({ name }));
let env = createEnv<Env>({ SHARDS: shards });

await (await shards.getByName("acme").fetch("https://do/")).json(); // { name: "acme" }
shards.names; // ["acme"]

Placement is the one thing a caller decides that cannot be read back off the stub, so resolutions records it. jurisdiction() returns a view over the same objects that tags what it resolves, and an id minted under one jurisdiction is refused by a view scoped to another, exactly as the platform refuses it — which is the mistake sharding code actually makes, deriving the id from the unscoped binding and resolving it through a scoped one:

shards.jurisdiction("eu").getByName("tenant-1", { locationHint: "weur" });

shards.resolutions; // [{ name: "tenant-1", locationHint: "weur", jurisdiction: "eu" }]

createFetcher(handler: FetcherHandler): FetcherMock

A Fetcher for a service binding or the static-asset binding, backed by a handler that produces the response for each request. Exposes requests (what it was asked for) and reset().

Every call is normalized to a Request whatever the caller passed, so assertions on method, path, and headers read the same as they would against the deployed Worker. A request is recorded before the handler runs, so a handler that throws still leaves evidence of the call. connect() throws: raw sockets have no in-memory equivalent.

let assets = createFetcher(() => new Response(null, { status: 404 }));
let env = createEnv<Env>({ ASSETS: assets });

await env.ASSETS.fetch("https://example.com/logo.png");
assets.requests[0]?.url; // "https://example.com/logo.png"

createExecutionContext<Props>(options?: ExecutionContextMockOptions<Props>): ExecutionContextMock<Props>

An ExecutionContext that records deferred work, with options.props exposed as ctx.props. Exposes waitUntilPromises, passedThroughOnException, aborted, abortReason, and settled().

settled() awaits every registered promise, including promises registered while it is awaiting, and rejects with the first failure so broken background work fails the test.

createEnv<Env>(bindings, options?: EnvMockOptions): Env

Builds the env object a Worker expects from the bindings a test supplies, keyed by binding name. Pass the generated binding type as the type argument to have the bindings checked against it. options.strict decides whether reading an unsupplied binding throws, and defaults to true; pass false only when the code under test genuinely treats a binding as optional.

Types

QueueMessageRecord<Body>, QueueConsumeResult<Body>, and SentEmailRecord are the shapes assertions read:

interface QueueMessageRecord<Body = unknown> {
	id: string;
	timestamp: Date;
	body: Body;
	attempts: number;
	contentType?: QueueContentType;
	delaySeconds?: number;
}

interface QueueConsumeResult<Body = unknown> {
	delivered: QueueMessageRecord<Body>[];
	acked: QueueMessageRecord<Body>[];
	retried: QueueMessageRecord<Body>[];
	deadLettered: QueueMessageRecord<Body>[];
}

interface SentEmailRecord {
	messageId: string;
	from: string;
	to: string[];
	cc: string[];
	bcc: string[];
	subject?: string;
	replyTo?: string;
	headers?: Record<string, string>;
	text?: string;
	html?: string;
	attachments?: EmailAttachment[];
	raw?: string;
}

Each factory's options and mock interfaces are exported under the matching names, so createRateLimit takes a RateLimitMockOptions and returns a RateLimitMock.

SQLite Engine

createD1Database() and createSqlStorage() run real SQL, and Bun and Node ship different built-in SQLite modules that cannot resolve each other. The @sdxc/cloudflare-mocks/sqlite subpath resolves to whichever the current runtime has through the bun export condition, so the same test file runs under either without the other's module appearing in its graph.

Both implementations satisfy one narrow interface, and the Node side normalizes the differences that would otherwise change results between runners: a missed get() reads as null, a statement with no result columns reports an empty column list, integral bindings are bound as INTEGER so integer division truncates, and bindings passed as a single array are flattened to a positional list. Both enable SQLite's legacy double-quoted string literals, so an identifier that does not resolve degrades to a string — worth knowing when a query returns a column name where you expected a value.

Where A Mock Is More Permissive Than The Platform

A mock is not the platform. These are the differences that matter, so a test that passes here is not mistaken for a guarantee about production.

D1. The engine is a local SQLite build, so anything SQLite accepts and D1 rejects passes here: unsupported SQL, ATTACH, extension functions, and larger result sets than D1 returns. A batch's atomicity comes from a SQLite transaction rather than from D1's own batching. Size and time limits — database size, statement duration, response size, bound parameter count — are unenforced, withSession() is a pass-through that advances a synthetic bookmark, run() and all() resolve to the same D1Result, exec() splits scripts on statement boundaries rather than newlines, and dump() throws.

Durable Object SQL and storage. Booleans are folded to 1/0 where the platform takes only null, numbers, strings, and byte buffers. A statement with no result columns and no bindings runs as a whole ;-separated script, so a migration executes in full. Transaction control is refused on the keyword a statement opens with, where the platform refuses it from its parser, so a form that parses to a transaction without opening with one of those keywords passes here. Key and value sizes and the storage quota are unenforced. setAlarm records a time and the test calls the object's alarm() handler itself, which is what makes the timing assertable. Bookmarks are placeholder strings, and WebSocket hibernation is bookkeeping: sockets, tags, auto-response pairs, and the event timeout are recorded, and nothing hibernates.

KV. A key disappears the instant its expiration passes, where the platform is eventually consistent and may serve a stale value for a short window. A put is visible to the next get, with no propagation delay and no cacheTtl behavior; cacheStatus is always null. Keys sort by JavaScript string comparison rather than UTF-8 byte order, which differs for some non-BMP keys.

R2. Only md5 is verified; a supplied sha1/sha256/sha384/sha512 is accepted as given. ssecKey is ignored and ssecKeyMd5 is never reported. Multipart parts may be any size, storageClass is stored verbatim, and Range header parsing covers a single bytes= range.

Queues, Analytics Engine, rate limiting, email. Delivery is manual: delaySeconds is recorded, and nothing is delivered until consume() is called. Rate limiting uses a fixed window keyed on the clock, so it approximates the platform's algorithm. Email is recorded rather than sent, and a raw MIME message's body is captured as text.

Namespaces, fetchers, and secrets. A namespace routes; the object behind a name is whatever the caller supplied, so pair it with createDurableObjectState to exercise the object itself. A locationHint and a jurisdiction are recorded on resolutions while every object lives in the same process. idFromName produces an id whose string form is the name rather than the platform's opaque 64-hex-digit id. Fetcher.connect() throws, and createSecretsStoreSecret models one binding's read, with no store, rotation, or caching window.

Absent. Reading ExecutionContext.exports, ExecutionContext.tracing, DurableObjectState.exports, or DurableObjectState.facets throws, because none has an in-memory equivalent. Hyperdrive, Vectorize, Workers AI, and Browser Rendering have no mock.

Pattern: Testing A Repository Against Real SQL

The point of a SQL-backed mock is that generated SQL is covered by ordinary unit tests.

import { createD1Database } from "@sdxc/cloudflare-mocks";
import { expect, test } from "vitest";

test("finds a user by email", async () => {
	let binding = createD1Database();
	await binding.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL)");

	let repository = createUserRepository(binding);
	await repository.create({ id: 1, email: "ada@example.com" });

	// A malformed statement or an unencoded value would throw here, not in production.
	expect(await repository.findByEmail("ada@example.com")).toMatchObject({ id: 1 });
});

db.exec() accepts a ;-separated script, so a migration file can be applied as it stands.

Pattern: Testing A Producer And Its Consumer Together

import { createEnv, createKVNamespace, createQueue } from "@sdxc/cloudflare-mocks";
import { expect, test } from "vitest";

test("a failed job is retried", async () => {
	let queue = createQueue<Job>();
	let env = createEnv<Env>({ JOBS: queue, CACHE: createKVNamespace() });

	await scheduleJobs(env);
	expect(queue.messages).toHaveLength(1);

	await expect(queue.consume((batch) => handleJobs(batch, env))).rejects.toThrow();

	// The handler failed, so the message is back with one attempt spent.
	expect(queue.messages[0]?.attempts).toBe(1);
});

Asynchronous bindings reject rather than throwing synchronously, matching the platform, so an expected failure reads as await expect(…).rejects.

Pattern: Testing A Durable Object By Construction

import { createDurableObjectState, createEnv } from "@sdxc/cloudflare-mocks";
import { expect, test } from "vitest";

test("counts within a window", async () => {
	let state = createDurableObjectState({ name: "tenant-1" });
	let counter = new Counter(state, createEnv<Env>({}));

	await counter.increment();
	await counter.increment();

	// Reads go through the same storage the object wrote to.
	expect(await state.storage.get<number>("count")).toBe(2);
});

Pattern: Asserting On Background Work

import { createExecutionContext, createKVNamespace } from "@sdxc/cloudflare-mocks";
import { expect, test } from "vitest";

test("caches the response after replying", async () => {
	let cache = createKVNamespace();
	let ctx = createExecutionContext();

	await handleRequest(new Request("https://example.com/"), { CACHE: cache }, ctx);

	// Nothing is cached until the deferred work runs.
	expect(await cache.get("https://example.com/")).toBeNull();
	await ctx.settled();
	expect(await cache.get("https://example.com/")).not.toBeNull();
});

Awaiting settled() is also what surfaces a rejected waitUntil promise, which is otherwise silent.