Two design choices make an Express 24,430 app testable, and both cost nothing: export a factory that builds the app instead of a module that calls listen, and take collaborators as arguments rather than importing them. So src/app.js exports createApp({ store = makeStore() } = {}), which mounts express.json(), binds the routes to makeBooksController(store) methods, adds a catch-all 404 and returns the app; server.js alone calls listen. Pull query parsing out of the handler and it becomes a table of inputs and outputs; for a handler itself, a stub of the two res methods you use is enough.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseListQuery, makeBooksController } from '../src/books-controller.js';
const fakeRes = () => {
const res = { statusCode: 200, body: undefined };
res.status = (c) => { res.statusCode = c; return res; }; // must return res
res.json = (p) => { res.body = p; return res; }; return res;
};
test('caps limit at 50 and floors offset at 0', () => {
assert.deepEqual(parseListQuery({ limit: '999', offset: '-5' }), { limit: 50, offset: 0 });
});
test('show returns 404 for an unknown id', async () => {
const store = { get: async (id) => (id === 1 ? { id: 1, title: 'Dune' } : null) };
const res = fakeRes();
await makeBooksController(store).show({ params: { id: '404' } }, res);
assert.equal(res.statusCode, 404);
assert.deepEqual(res.body, { error: { code: 'not_found' } }); });Write the test before the clamp exists and node --test shows what is missing, + marking the value it got and - the value you expected:
✖ caps limit at 50 and floors offset at 0 (2.0917ms)
✔ show returns 404 for an unknown id (0.3728ms)
ℹ tests 2 ℹ pass 1 ℹ fail 1
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
+ limit: 999,
+ offset: -5
- limit: 50,
- offset: 0
}The fix is Math.min(Number(query.limit ?? 10) || 10, 50) with a matching Math.max for the offset, after which both lines are ticks. Chaining is the detail most fake responses get wrong: res.status(404).json(...) only works because status returns res.