Skip to content

Testing

Here’s a test of a real Worker, with its KV namespace and D1 database:

test/runtime.test.ts
test('a handler runs with in-memory resources in place of its bindings', async () => {
const fakes = Layer.mergeAll(Kv.memory(Cache, [['visits', '41']]), sqlite(Db));
const response = await Effect.runPromise(Worker.handle(api, get('/hello')).pipe(Effect.provide(fakes)));
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { visits: 42, logged: 1 });
});

No mocks, no fake env, no Cloudflare. Worker.handle runs the Worker’s own fetch, with its own Layers, and leaves its resources for you to provide. Kv.memory is a KV namespace in a Map; sqlite(Db) is real SQLite, from Node, with Db’s migrations applied.

It works because of how a handler gets a resource: it asks for Cache, and something answers. On Cloudflare, the binding does. In a test, you do. And the compiler checks you’ve provided everything: leave out sqlite(Db) and Effect.runPromise won’t type-check, since the Effect still needs Db.

Run tests with Node’s own runner, from packages/infra:

Terminal window
node --import ../ship/scripts/register.ts --test test/runtime.test.ts
Resource Stand-in You can check
KV Kv.memory(Cache, entries?) what’s in it, through the client
D1 sqlite(Db), from @forge-effect/infra/testing real SQL, migrations applied
R2 R2.memory(Files) objects, through the client
Queue Queue.memory(Jobs): .layer, and .sent every message sent, decoded
Rate limit RateLimit.memory(Signing) it counts, and .reset()
Analytics Engine Analytics.memory(Clicks) .points() written
Secret Secret.memory(Key, 'sk_test_…')
Secrets Store SecretsStore.memory(Key, 'value')
Variable Var.memory(Title, value)
Files Assets.memory(Site, { '/index.html': '…' })
Workers AI Ai.memory(Model, (model, inputs) => output) your stand-in model
Vectorize Vectorize.memory(Docs) every vector compared
Browser, Images Browser.memory(Chrome), Images.memory(Pictures)
Email Email.memory(Pager) .sent()
A Worker’s contract Users.memory({ get, … }), or Worker.connect(Users, users) see below
A Durable Object Testing.object(Room, room) see below

Each is a Layer (a queue’s is its .layer), so Layer.mergeAll them and provide the lot.

Effect.provide builds the Layers for each run, so two Effect.runPromise calls get two empty databases. A Worker keeps its resources across requests, and usually your test should too. ManagedRuntime builds them once:

test/guestbook.test.ts
// One database for every request, as one Worker has.
const runtime = ManagedRuntime.make(Layer.mergeAll(sqlite(Book), RateLimit.memory(Signing)));
const call = (method: string, body?: unknown, ip = '1.2.3.4') =>
runtime.runPromise(
Effect.flatMap(
Worker.handle(guestbook, new Request('https://x.dev/', { method, headers: { 'cf-connecting-ip': ip }, ...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }) })),
(r) => Effect.promise(async () => ({ status: r.status, body: await r.text() })),
),
);
assert.deepEqual(await call('GET'), { status: 200, body: '[]' });
assert.equal((await call('POST', { name: 'Ada', message: 'Hello!' })).status, 201);

Now a signature, then a list, see the same database, and the rate limit counts across calls, so the fourth signature from one address is a 429. await runtime.dispose() at the end.

Each kind of handler has a runner, with the same idea: the Worker’s own handler, its resources left to you.

A cron: Worker.runCron(worker, schedule) runs the crons for that schedule.

test/runtime.test.ts
await Effect.runPromise(
Worker.runCron(api, '0 * * * *').pipe(
Effect.provide(Layer.mergeAll(Kv.memory(Cache), sqlite(Db))),
Effect.provide(logger),
),
);

A queue consumer: Worker.runQueue(worker, queue, messages) delivers a batch. messages(bodies), from @forge-effect/infra/testing, makes one as workerd would, and remembers what was acked and retried. Here, one app sends, and another’s consumer gets what was sent:

test/media.test.ts
const batch = messages(queue.sent);
const index = sqlite(Index);
const recent = await Effect.runPromise(
Effect.gen(function* () {
yield* Worker.runQueue(indexer, 'uploaded', batch.messages);
return yield* Worker.handle(indexer, new Request('https://catalog.dev/recent'));
}).pipe(Effect.provide(Layer.mergeAll(uploads, index))),
);
assert.deepEqual(batch.retried, []);

A Tail Worker: Worker.runTail(worker, events); there’s an example in Settings, logs and Tail Workers.

An email handler: Worker.runEmail(worker, message), with a message shaped as Email Routing hands one over:

test/untested.test.ts
const pager = Email.memory(Pager);
const runtime = ManagedRuntime.make(Layer.mergeAll(Kv.memory(Mail, []), pager));
const urgent = mail('ada@example.org', 'URGENT: site down');
await runtime.runPromise(Worker.runEmail(inbox, urgent.message));
assert.deepEqual(urgent.done, { rejected: undefined, forwarded: ['team@example.com'] });

(mail is a small helper in that test that builds the message and records what the handler did with it.)

When api calls users through a contract, you have two choices.

Stand in for users, with the contract’s own memory. You write each method’s behaviour; the compiler holds you to the contract’s types:

test/shop.test.ts
test('api answers 404 for a user users says is missing', async () => {
// `users`, as the contract says it behaves: no Worker, no binding.
const users = Users.memory({
get: ({ id }) => (id === 'ada' ? Effect.succeed(new User({ id, name: 'Ada', joined: new Date(0) })) : Effect.fail(new UserNotFound({ id }))),
add: ({ name }) => Effect.succeed(new User({ id: 'u1', name, joined: new Date() })),
names: () => Effect.succeed(new Response('Ada\n').body!),
});
const response = await Effect.runPromise(Worker.handle(api, new Request('https://shop.dev/users/nobody')).pipe(Effect.provide(users)));
assert.equal(response.status, 404);
assert.deepEqual(await response.json(), { _tag: 'UserNotFound', id: 'nobody' });
});

Or wire in the real users, with Worker.connect. Then you provide what it needs:

test/contract.test.ts
const layer = Worker.connect(Users, users).pipe(Layer.provide(Layer.succeed(Db, D1.client(sqliteBinding(Db)))));

Either way, payloads and replies are cloned as workerd’s RPC clones them, so a test passes only with what a real call can carry.

Testing.object(Room, room) runs objects in your test, each with its own SQLite in memory, its migrations applied:

test/durable.test.ts
const run = <A, E>(effect: Effect.Effect<A, E, Room>, layer = Testing.object(Room, room)) =>
Effect.runPromise(Effect.provide(effect, layer));
test('each name is its own object, with its own storage, which knows its name', async () => {
const [a, b] = await run(
Effect.gen(function* () {
const rooms = yield* Room;
yield* rooms.byName('a').post({ user: 'ada', text: 'one' });
yield* rooms.byName('a').post({ user: 'ada', text: 'two' });
yield* rooms.byName('b').post({ user: 'bob', text: 'three' });
return [yield* rooms.byName('a').about(), yield* rooms.byName('b').about()] as const;
}),
);
assert.deepEqual(a, { name: 'a', messages: 2 });
assert.deepEqual(b, { name: 'b', messages: 1 });
});

The layer has a few extras: layer.alarms fires every alarm that’s set, and for an object with a WebSocket, connect gives a test client (send, received, close) and hibernate puts an object to sleep. More in Durable Objects.

Workflow.test runs a workflow in memory, then replays it with every step kept, as Cloudflare would after a sleep, and fails if the body decides different steps the second time:

test/workflow.test.ts
const ran = await Effect.runPromise(
Workflow.test(onboarding, { userId: 'u1' }, { events: [{ type: 'survey', payload: { score: 9 } }] }).pipe(Effect.provide(users([['u1', 'Ada']]))),
);
assert.ok(Exit.isSuccess(ran.exit), String(ran.exit));
assert.deepEqual(ran.steps, ['load user', 'a moment', 'wait for survey', 'record']);
assert.deepEqual(ran.sleeps, ['a moment']);

(users there is the test’s own Kv.memory(Users, entries), the KV namespace the steps read.) It gives you the exit, the steps and the sleeps. Replay bugs are the nastiest kind in workflows, and this catches them on your laptop. More in Workflows.

In-memory tests are quick, and catch most things. When you want the real runtime (your bundle, the real bindings, calls over real service bindings), start the app with dev, the function behind infra dev:

test/dev.test.ts
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { Effect } from 'effect';
import { dev } from '@forge-effect/infra/deploy';
test('in workerd: signed and listed', async () => {
// The whole app, as `infra dev` runs it, on a free port, with fresh state.
const running = await Effect.runPromise(dev('example/guestbook/app.ts', { port: 0, persist: mkdtempSync(join(tmpdir(), 'dev-')) }));
try {
const signed = await running.mf.dispatchFetch('http://guestbook.localhost/', {
method: 'POST',
body: JSON.stringify({ name: 'Ada', message: 'Hello from workerd' }),
});
assert.equal(signed.status, 201);
const listed = await fetch(running.url);
assert.match(await listed.text(), /Hello from workerd/);
} finally {
await Effect.runPromise(running.stop);
}
});
  • port: 0 takes any free port; persist a fresh folder, so each run starts empty. (persist: false keeps it all in memory.)
  • running.mf.dispatchFetch reaches any Worker by its host, without a real network; running.url is the first Worker’s, for a plain fetch.
  • The migrations are applied before dev returns.

This one takes a couple of seconds, against milliseconds for the in-memory tests, so keep most tests in memory and a few end to end.

  • Workers AI and Vectorize can’t run locally, so Ai.memory and Vectorize.memory are the only way to test those handlers off Cloudflare. Ai.memory takes your stand-in model as a function.
  • Worker.handle takes an env option for a handler that reads WorkerEnv directly.
  • A failure your handler doesn’t handle answers 500 in a test, as it would on Cloudflare.