Testing

The in-memory harness that runs your real handler with no infrastructure, what a mock cannot tell you, and the trap of a declared contract that is stricter than the handler.

A scaffolded app is npm test-green out of the box, and the suite runs your real handler against an in-memory data store — no tenant, no network, no infrastructure.

Terminal
npm test

The harness

Two things make it work. First, build your routes from an injected db so a test can hand you a different one:

src/main.js
function buildApp(db) {
  const app = createApp({ name: 'serials' });
  mountCrud(app, db.device_serials, { path: '/serials', columns: ENTITIES.device_serials.columns });
  app.post('/serials/defaults', async (c) => c.json(await ensureDefaults(db)));
  return app;
}

module.exports = async (context) => buildApp(createDb({ adapter: 'runtime', context })).handler()(context);
module.exports.buildApp = buildApp;
module.exports.ensureDefaults = ensureDefaults;

Second, call the exported handler with a context shaped like the platform's, carrying a tenant identity header:

test/serials.test.js
'use strict';

const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const handler = require('../src/main');

function jwt(claims) {
  const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
  return `${b({ alg: 'RS256' })}.${b(claims)}.sig`;
}

function call(method, path, { body = null, headers = {} } = {}) {
  let captured;
  const ctx = {
    req: {
      method, path, body,
      headers: {
        'x-revenexx-context': jwt({ sub: 'svc', tenant_id: 'acme' }),
        'x-revenexx-tenant': 'acme',
        ...headers,
      },
    },
    res: { json: (b, code = 200) => (captured = { body: b, code }) },
    log: () => {}, error: () => {},
  };
  return handler(ctx).then(() => captured);
}

test('defaults seed once, then are idempotent', async () => {
  assert.deepEqual((await call('POST', '/serials/defaults')).body.created, ['main']);
  assert.deepEqual((await call('POST', '/serials/defaults')).body.created, []);
});

test('create then read back', async () => {
  const created = await call('POST', '/serials', { body: { serial: 'SN-1', product_id: 'p1' } });
  assert.equal(created.code, 201);
  const read = await call('GET', `/serials/${created.body.id}`);
  assert.equal(read.body.serial, 'SN-1');
});

The scaffold writes a working version of this for you. Grow it rather than replacing it.

What is worth testing here

The mock is right about your code and wrong about the database, so aim the suite at the code:

  • Route wiring. Every path answers, with the status you intended. A create is 201.
  • Guards. Each badRequest path, by sending the bad input. A guard you never test is a guard that can rot.
  • Branching in the exported handler. An event delivery with x-revenexx-trigger: event, a schedule tick with x-revenexx-schedule, and a normal request all take different paths before the router.
  • Idempotency. Call /defaults twice and assert the second one creates nothing.
  • Error mapping. A missing row is a 404, a conflicting state is a 409.
  • The health route. It answers without a tenant identity.

Test the 4xx paths as deliberately as the happy one. They are the half of your contract a partner meets on their first attempt.

What a mock cannot tell you

An in-memory store has no constraints, no defaults, no cascades and no rows you did not put there. So it cannot fail in any of the ways your database can:

  • Constraints. No unique, no check, no notNull. The 409 your API is supposed to return on a duplicate will not happen in a test.
  • Foreign keys. No cascade, and a write pointing at a missing parent succeeds.
  • SQL defaults. now() and gen_random_uuid() are database expressions; a mock's substitute is not the value production will hold.
  • Generated columns. Evaluated by PostgreSQL; absent or stale in a mock.
  • Migrations. No populated table, so nothing tells you a new notNull column without a default cannot be applied to one.
  • Tenant isolation and scoping. One tenant, no scope context.
  • Real ordering and paging. An in-memory array is insertion-ordered; a table is not, unless you declared an order.

Which gives you the division of labour:

Terminal
npm test                        # mock: logic, routes, guards, branching, error mapping
revenexx tenants use acme-staging
revenexx deploy app             # then verify the database half against real data

The remote adapter sits between the two: your test suite, run against a dev tenant, for the fidelity a mock cannot give. Use it for the handful of tests where the constraint is the behaviour. See Adapters.

The stricter-contract trap

This is the failure a green test suite is least likely to catch, and the one that costs the most time.

The gateway validates a request against your declared request schema before your function is invoked. So if the declared contract is stricter than your handler:

  • The request is rejected with a 400.
  • Your handler never runs.
  • Nothing appears in your execution log.
  • Your tests pass, because they call the handler directly and skip the gateway entirely.

A partner reports a 400 on a request that looks correct, and there is no trace of it anywhere on your side.

The rule: the declared contract must never be stricter than the handler.

In the contractIn the handlerResult
required: ["sku", "quantity"]Accepts a missing quantity, defaulting to 1Working requests rejected
enum: ["a", "b"]Accepts c tooValid requests rejected
minItems: 1Handles an empty arrayRejected before you can answer nicely

How to keep them aligned:

Generate the schema from the same constants your guard uses, where you can. scripts/capabilities.js is JavaScript — the enum and the guard can read one array.

Mirror each guard in the contract, not more. If parseItems enforces "one to two hundred items, each with a product_id or a sku", the schema says minItems: 1, maxItems: 200 and anyOf — and nothing extra.

When you loosen a guard, loosen the schema in the same commit. And when you tighten a schema, remember that tightening it is a breaking change.

Verify one real request per capability through the gateway after a deploy. That is the only check that exercises the validation layer at all. See Verifying live.

In CI

CI
npm ci
revenexx apps capabilities --write
revenexx apps generate
git diff --exit-code            # generated files must be in step with schema.json
npm test

Failing on a diff is the cheap way to catch the other silent problem: an app whose generated capability register or typed client is older than its schema.json will deploy and then behave in ways the source does not explain.

Next steps

Was this page helpful?