Testing
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.
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:
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:
'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
badRequestpath, 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 withx-revenexx-schedule, and a normal request all take different paths before the router. - Idempotency. Call
/defaultstwice and assert the second one creates nothing. - Error mapping. A missing row is a
404, a conflicting state is a409. - 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, nocheck, nonotNull. The409your 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()andgen_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
notNullcolumn without adefaultcannot 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:
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 contract | In the handler | Result |
|---|---|---|
required: ["sku", "quantity"] | Accepts a missing quantity, defaulting to 1 | Working requests rejected |
enum: ["a", "b"] | Accepts c too | Valid requests rejected |
minItems: 1 | Handles an empty array | Rejected 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
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
- Adapters —
mock,remote,runtime. - Verifying live — the checks only the gateway can do.
- Errors — the statuses to assert.
- Validate — what the schemas catch before any of this.