@groveback/testkit
v0.1.0
Published
Unit-test harness for Groveback functions — invoke HTTP endpoints, event triggers and pre-write hooks in-process with an in-memory ctx.db mock, auth builders and worker-faithful semantics.
Maintainers
Readme
@groveback/testkit
Unit-test harness for Groveback functions.
Keep your function code in files, import the handlers into your own test runner (bun test,
Vitest, Jest), and invoke them exactly the way the platform would — with an in-memory
ctx.db, auth builders and the same response mapping your callers see. Because the handler
runs in-process, your runner's coverage instrumentation applies to it like any other module.
npm i -D @groveback/testkitTesting an HTTP endpoint
// functions/get-order.ts — deployed as an http trigger: GET /orders/:id
export default async (_event, ctx) => {
if (!ctx.auth) return { status: 401, body: { error: 'sign in' } };
const order = await ctx.db.findOne('orders', { id: ctx.request.params.id, owner: ctx.auth.uid });
if (!order) return { status: 404, body: { error: 'not found' } };
return order; // plain return → 200
};import { describe, expect, test } from 'bun:test'; // or vitest
import { invokeHttp, createTestDb, authContext } from '@groveback/testkit';
import handler from '../functions/get-order.js';
test('owner reads their order', async () => {
const db = createTestDb({ orders: [{ id: 'o1', owner: 'user_1', total: 5 }] });
const { response } = await invokeHttp(handler, {
method: 'GET',
path: '/orders/o1',
params: { id: 'o1' },
auth: authContext({ uid: 'user_1' }),
db,
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ total: 5 });
});
test('anonymous is rejected', async () => {
const { response } = await invokeHttp(handler, { params: { id: 'o1' }, db: createTestDb() });
expect(response.status).toBe(401);
});invokeHttp mirrors the platform's response mapping: a { status, headers, body } return
passes through, any other value becomes a 200 body, a thrown error becomes a generic
500 { error: 'function error' } (the message is never echoed to callers — but it IS on
result.error for your assertions), a timeout becomes 504.
Testing an event trigger
import { invokeEvent, databaseEvent } from '@groveback/testkit';
import onPostInsert from '../functions/on-post-insert.js';
test('logs the new post', async () => {
const result = await invokeEvent(
onPostInsert,
databaseEvent({ type: 'insert', collection: 'posts', document: { id: 'p1', title: 'hi' } }),
);
expect(result.status).toBe('ok');
expect(result.logs).toContain('post created: p1');
});Event functions get a bare ctx (projectId + function) — no ctx.db, ctx.auth or
ctx.request — same as production.
Testing a pre-write hook
import { runPreHook, PreHookRejection } from '@groveback/testkit';
import validatePost from '../functions/validate-post.js';
test('trims the title', async () => {
const { document } = await runPreHook(validatePost, {
event: 'insert',
collection: 'posts',
document: { title: ' Hello ' },
});
expect(document.title).toBe('Hello');
});
test('rejects empty titles', async () => {
await expect(
runPreHook(validatePost, { event: 'insert', collection: 'posts', document: { title: '' } }),
).rejects.toThrow(PreHookRejection);
});Pre-hook semantics match the write path: { abort: true, reason } rejects, { document }
replaces the doc for the next hook (ignored on delete), and a hook that throws or times out
fails closed. Pass an array ([{ name, handler }, …]) to test a chain — order it by name,
as the runtime does.
The ctx.db mock
createTestDb(seed?) implements the exact service-role surface a deployed function gets —
find / findOne / count / insertOne (auto-stamps doc_… ids) / updateMany
($set / $unset / $inc) / 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). Unsupported operators throw rather than silently mis-matching.
Test-side helpers that a real function never sees: seed, all, get.
Fidelity
The harness reproduces the worker's observable semantics: event/ctx/results (and every
ctx.db call) cross a structured-clone boundary like postMessage, console.* is captured
as run logs (200-line cap, objects JSON-stringified), and timeouts resolve
status: 'timeout' with empty logs. A parity suite in the main repo runs the same code
through the real Worker sandbox and this harness and asserts identical outcomes.
Not reproduced: the process-level sandbox. In your tests the handler can see your env and imports and a busy-loop is not killable — deployed functions still run fully sandboxed.
API
invokeHttp(handler, { method, path, params, query, headers, body, auth, db, ... })→{ response, status, logs, result, error, durationMs }invokeEvent(handler, event, { name?, projectId?, timeoutMs? })→FunctionResultrunPreHook(handler | handlers[], { event, collection, document })→{ document, runs }/ throwsPreHookRejectioninvokeFunction(handler, opts)— the low-level primitive the above build oncreateTestDb(seed?),authContext(...),adminContext(...),httpRequest(...),databaseEvent(...)- Types:
FunctionCtx,FunctionHandler,HttpRequestContext,ServiceRoleDb,AuthContext,HttpFunctionResponse
Handlers can be passed as the function itself or as a module namespace
(await import('./fn.ts')).
License
Apache-2.0
