Testing your app
An @ltv/cwb app is a regular Hono app, so most tests send requests with app.request() and assert on the envelope. The one thing to plan for: the library imports cloudflare:workers when it loads, and that module only exists inside workerd.
| Approach | Runs in | cloudflare:workers | Bindings |
|---|---|---|---|
| Unit tests with a module mock | Bun or Node test runner | mocked | fakes, or real local bindings from getPlatformProxy() |
| Tests inside workerd (for example Cloudflare's Vitest integration) | workerd | real | real local bindings |
End-to-end tests with createTestHarness() | Node test runner driving workerd | real | real local bindings |
Mock cloudflare:workers
The library reads NODE_ENV, LOG_LEVEL and SERVICE_NAME from cloudflare:workers when it loads, so the mock must be installed before anything imports @ltv/cwb. With bun test, use a preload file (this is how the library tests itself):
[test]
preload = ["./tests/cloudflare-workers-mock.ts"]
import {mock} from 'bun:test';
// Values read at module load come from this initial object
export const workerEnv: Record<string, unknown> = {
LOG_LEVEL: 'silent', // keep test output clean
// NODE_ENV unset: production defaults, like a deployed Worker
};
await mock.module('cloudflare:workers', () => ({env: workerEnv}));
env.default.* readers see later changes to workerEnv; the constants (NODE_ENV, isDev, LOG_LEVEL, SERVICE_NAME) keep their load-time values.
Request-level tests with app.request
Export the app from a module that doesn't start anything, then call app.request(path, init, env, executionCtx):
import {describe, expect, test} from 'bun:test';
import type {ApiResponse} from '@ltv/cwb';
import app from '../src/index';
describe('GET /api/v1/users/:id', () => {
test('returns the success envelope', async () => {
const res = await app.request('/api/v1/users/1');
expect(res.status).toBe(200);
expect(res.headers.get('x-request-id')).toBeTruthy();
const body = (await res.json()) as ApiResponse<{id: string; name: string}>;
expect(body.success).toBe(true);
if (body.success) expect(body.data.name).toBe('Jane');
});
test('unknown ids return 404 NOT_FOUND', async () => {
const res = await app.request('/api/v1/users/2');
expect(res.status).toBe(404);
const body = (await res.json()) as ApiResponse;
expect(body.success ? undefined : body.error.code).toBe('NOT_FOUND');
});
});
Production masking in tests
The error handler decides masking per request from c.env.NODE_ENV, then the load-time value. Pass an env to see both behaviors without reloading modules:
const masked = await app.request('/api/v1/boom'); // NODE_ENV unset: production
// {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error",...}}
const unmasked = await app.request('/api/v1/boom', {}, {NODE_ENV: 'test'});
// the original error message is returned
Testing with production defaults catches responses that would leak details after deployment.
Background work and waitUntil
Without an execution context, getWaitUntil(c) returns undefined: cache writes and error reporters still start, but nothing waits for them. Pass a stub to collect and await those promises:
const pending: Promise<unknown>[] = [];
const executionCtx = {
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
passThroughOnException: () => {},
props: {},
} as unknown as ExecutionContext;
const res = await app.request('/api/v1/notes/1', {}, bindings, executionCtx);
await Promise.all(pending); // e.g. the KV cache write has finished
Real local D1 and KV with getPlatformProxy
Datasource and cache code is best tested against real bindings rather than hand-written fakes. wrangler's getPlatformProxy() starts local, in-memory D1 and KV from your wrangler.jsonc:
import {getPlatformProxy} from 'wrangler';
export async function createBindings() {
const proxy = await getPlatformProxy<Env>({configPath: 'wrangler.jsonc', persist: false});
return {bindings: proxy.env, dispose: () => proxy.dispose()};
}
import {afterAll, beforeAll, expect, test} from 'bun:test';
import app from '../src/index';
import {createBindings} from './local-bindings';
let bindings: Env;
let dispose: () => Promise<void>;
beforeAll(async () => {
({bindings, dispose} = await createBindings());
await bindings.DB.exec('CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)');
});
afterAll(() => dispose());
test('creates a note in D1', async () => {
const res = await app.request(
'/api/v1/notes',
{method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({title: 'hello'})},
bindings
);
expect(res.status).toBe(201);
});
For tenant datasources, set the tenant the same way production does (for example through the header or token your middleware reads), and assert that another tenant can't see the rows.
End-to-end tests in workerd
To test the bundled Worker in the real runtime, wrangler's createTestHarness() runs it locally and exposes fetch plus the Worker's bindings. The library's own end-to-end suite runs the basic Worker example this way:
import assert from 'node:assert/strict';
import {after, before, test} from 'node:test';
import {createTestHarness} from 'wrangler';
const server = createTestHarness({workers: [{configPath: 'wrangler.jsonc'}]});
const worker = server.getWorker<Env>();
before(async () => {
await server.listen();
await worker.applyD1Migrations('DB');
});
after(() => server.close());
test('GET /health', async () => {
const res = await server.fetch('/health');
assert.equal(res.status, 200);
assert.ok(res.headers.get('x-request-id'));
});
test('reads the Worker bindings directly', async () => {
const env = await worker.getEnv();
const {keys} = await env.KV.list({prefix: 'notes:'});
assert.ok(Array.isArray(keys));
});
- The suite runs under Node's test runner (
node --test), because wrangler's local dev runtime does not serve requests when hosted by Bun 1.4. - KV writes from
withCachehappen after the response, throughwaitUntil. Poll KV until the entry appears instead of asserting immediately.
Testing inside workerd with Vitest
Cloudflare's Vitest integration runs test files inside workerd, where cloudflare:workers and bindings exist, so no module mock is needed. Follow Cloudflare's documentation for its setup; the app.request assertions above work unchanged.