Testing
Here’s a test of a real Worker, with its KV namespace and D1 database:
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:
node --import ../ship/scripts/register.ts --test test/runtime.test.tsA stand-in for every resource
Section titled “A stand-in for every resource”| 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.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.
One Worker, many requests: ManagedRuntime
Section titled “One Worker, many requests: ManagedRuntime”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:
// 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.
Crons, queues, Tail Workers and email
Section titled “Crons, queues, Tail Workers and email”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.
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:
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:
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.)
Workers that call Workers
Section titled “Workers that call Workers”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('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:
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.
Durable Objects
Section titled “Durable Objects”Testing.object(Room, room) runs objects in your test, each with its own
SQLite in memory, its migrations applied:
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.
Workflows
Section titled “Workflows”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:
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.
All the way to workerd
Section titled “All the way to workerd”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:
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: 0takes any free port;persista fresh folder, so each run starts empty. (persist: falsekeeps it all in memory.)running.mf.dispatchFetchreaches any Worker by its host, without a real network;running.urlis the first Worker’s, for a plainfetch.- The migrations are applied before
devreturns.
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.
Good to know
Section titled “Good to know”- Workers AI and Vectorize can’t run locally, so
Ai.memoryandVectorize.memoryare the only way to test those handlers off Cloudflare.Ai.memorytakes your stand-in model as a function. Worker.handletakes anenvoption for a handler that readsWorkerEnvdirectly.- A failure your handler doesn’t handle answers 500 in a test, as it would on Cloudflare.