Skip to content

Testkit

Terminal window
npm i -D @groveback/testkit

Import 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.

invokeHttp(handler, opts?): Promise<HttpInvokeResult>
invokeEvent(handler, event, opts?): Promise<FunctionResult>
runPreHook(handlers, opts): Promise<PreHookOutcome>
invokeFunction(handler, opts?): Promise<FunctionResult> // the low-level primitive

handler 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.

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.

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.

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.

createTestDb(seed?: Record<string, Doc[]>): TestDb

Implements 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).

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.

authContext(input?): AuthContext // defaults: uid 'user_test', role 'user', pid 'proj_test', no permissions
adminContext(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.*
}

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.

FunctionCtx · FunctionHandler · HandlerLike · IntegrationsLike · HttpRequestContext · ServiceRoleDb · FunctionResult · HttpFunctionResponse · AuthContext · Doc · Filter