Testkit
npm i -D @groveback/testkitImport your handler files directly, so your own runner — bun test, Vitest or Jest — instruments them for coverage, then invoke them the way the platform would. Plain Node ≥ 18.
Invokers
Section titled “Invokers”invokeHttp(handler, opts?): Promise<HttpInvokeResult>invokeEvent(handler, event, opts?): Promise<FunctionResult>runPreHook(handlers, opts): Promise<PreHookOutcome>invokeFunction(handler, opts?): Promise<FunctionResult> // the low-level primitivehandler is the function itself or its module namespace (await import('./fn.ts')) —
the harness resolves default either way.
interface InvokeOptions { event?: Record<string, unknown>; projectId?: string; // default 'proj_test' name?: string; // ctx.function, default 'fn-under-test' auth?: AuthContext | null; request?: HttpRequestContext; db?: ServiceRoleDb; integrations?: IntegrationsLike; timeoutMs?: number; // default 10_000}invokeFunction never rejects — errors and timeouts land in status, exactly like a real
run-log entry.
Testing an HTTP endpoint
Section titled “Testing an HTTP endpoint”import { invokeHttp, httpRequest, authContext, createTestDb } from '@groveback/testkit';import handler from '../groveback/functions/checkout.ts';
test('rejects anonymous callers', async () => { const { response } = await invokeHttp(handler, { method: 'POST', path: '/checkout/1' }); expect(response.status).toBe(401);});
test('returns the order', async () => { const db = createTestDb({ orders: [{ id: 'o1', total: 42 }] }); const { response } = await invokeHttp(handler, { method: 'POST', path: '/checkout/o1', params: { id: 'o1' }, auth: authContext({ uid: 'user_1' }), db, }); expect(response.body.order.total).toBe(42);});invokeHttp mirrors the platform’s response mapping verbatim: a { status, headers, body }
return passes through; any other value becomes a 200 with that body; an error becomes a
generic 500 { error: 'function error' } (never echoing the message to callers — but it
is on result.error for your assertions); a timeout becomes 504; a rejected run becomes
503.
Testing a database trigger
Section titled “Testing a database trigger”import { invokeEvent, databaseEvent, createTestDb } from '@groveback/testkit';
const db = createTestDb();await invokeEvent(handler, databaseEvent({ type: 'insert', collection: 'orders', document: { id: 'o1', total: 42 },}), { db });
expect(db.all('audit')).toHaveLength(1);databaseEvent builds the change-feed payload shape a trigger receives. documentId falls
back to document.id, then to doc_test.
Testing a pre-hook
Section titled “Testing a pre-hook”import { runPreHook, PreHookRejection } from '@groveback/testkit';
test('rejects a bad email', async () => { await expect(runPreHook(handler, { event: 'insert', collection: 'users', document: { email: 'nope' }, })).rejects.toThrow(PreHookRejection);});
test('normalizes', async () => { const { document } = await runPreHook(handler, { event: 'insert', collection: 'users', document: { email: 'A@B.COM' }, }); expect(document.normalizedEmail).toBe('a@b.com');});It matches the runtime exactly: fail-closed, { abort: true, reason } throws a
PreHookRejection, and { document } replaces the doc for the next hook (ignored for
delete). Pass handlers in the order the runtime would run them — deployed functions are sorted
by name.
The test database
Section titled “The test database”createTestDb(seed?: Record<string, Doc[]>): TestDbImplements the exact service-role surface a deployed function gets — find, findOne,
count, insertOne (auto-stamping a doc_… id when absent), updateMany, deleteMany —
over the same Mongo query subset the backend’s in-memory store supports: equality, dotted
paths, $and, $or, $in, $ne, $gt/$gte/$lt/$lte, and $set/$unset/$inc for
updates. Unsupported operators throw rather than silently mis-matching.
Test-side helpers a real function never sees: seed(collection, docs), all(collection),
get(collection, id).
Fake integrations
Section titled “Fake integrations”import { fakeIntegrations } from '@groveback/testkit';
const integrations = fakeIntegrations({ 'notifier.send': { id: 'msg_1' }, 'flaky.*': () => { throw new Error('down'); },});
await invokeEvent(handler, event, { integrations });expect(integrations.calls).toEqual([ { integration: 'notifier', op: 'send', args: { to: 'a@example.com' } },]);Lookup order is "<integration>.<op>" → "<integration>.*" → "*". A function stub is
called with the args — throw to simulate a failure; anything else is returned as-is.
Unstubbed calls resolve to { stubbed: true, integration, op } — the same shape the
platform’s stored-test stub returns, so handlers behave identically under both harnesses.
Stored dashboard tests accept the same map as JSON, where { "$error": "message" } replaces a
throwing function stub.
Context builders
Section titled “Context builders”authContext(input?): AuthContext // defaults: uid 'user_test', role 'user', pid 'proj_test', no permissionsadminContext(input?): AuthContext // role 'admin', permissions ['*'], uid 'user_admin'httpRequest(input?): HttpRequestContext // GET / with empty params, query, headers
interface AuthContextInput { uid?: string; role?: string; pid?: string; permissions?: Iterable<string>; // fail-closed set, empty by default token?: Record<string, unknown>; // custom JWT claims, reachable as $auth.token.*}Fidelity
Section titled “Fidelity”Reproduced: event, ctx and results cross a structuredClone boundary exactly like
postMessage — Dates and Sets survive, functions do not, and a non-cloneable return fails
here like it fails there. Every ctx.db and ctx.integrations call clones its arguments and
results. console.log/info/warn/error/debug is captured as run logs with a 200-line cap,
non-strings JSON-stringified. A timeout resolves status: 'timeout' with empty logs,
matching the real runner’s killer.
Not reproduced: the process sandbox. The handler runs in your test process — the environment is visible, imports are shared, and a busy loop is not killable. Deployed functions still run fully sandboxed.
A parity suite in the repo runs the same code through the real Worker sandbox and this harness and asserts identical outcomes, so the gap stays where it is documented.
Exported types
Section titled “Exported types”FunctionCtx · FunctionHandler · HandlerLike · IntegrationsLike ·
HttpRequestContext · ServiceRoleDb · FunctionResult · HttpFunctionResponse ·
AuthContext · Doc · Filter