Answering HTTP requests and talking to a database are the two things people stop testing first, because both look like they need a running environment. Neither does. Bind the server to port 0 and the operating system hands you a free port, so many test files can run in parallel without colliding. Start it in before, close it in after, and use the global fetch: nothing is stubbed, and the real routing and status codes run.
import { once } from 'node:events';
import { DatabaseSync } from 'node:sqlite';
describe('the products API', () => {
let db, server, base;
before(async () => {
db = new DatabaseSync(':memory:');
server = createApp(createRepo(db));
server.listen(0);
await once(server, 'listening');
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => { server.close(); db.close(); });
it('stores a product and reads it back', async () => {
const res = await fetch(`${base}/products`, {
method: 'POST', body: JSON.stringify({ name: 'Kettle', price: 2400 }),
});
assert.equal(res.status, 201);
const list = await fetch(`${base}/products?q=Ket`).then((r) => r.json());
assert.deepEqual(list, [{ name: 'Kettle', price: 2400 }]);
});
});▶ the products API ✔ stores a product and reads it back (28.2161ms) ✔ the products API (33.4273ms) ℹ tests 1 ℹ pass 1
A sibling test posting { price: 900 } and asserting a 422 with { error: 'name is required' } covers the validation path.
The data layer is node:sqlite with a :memory: database: a real SQL engine, built into Node, created and destroyed per suite, with nothing left on disk. createRepo(db) prepares the statements the handler needs and returns { list, add }, so the handler depends on an object rather than on a driver — that seam is what lets it run against SQLite 4,756 here and the real driver in production. node:sqlite is stability 1.2, release candidate, on Node 25 and 26.
The same shape works for Mongo, except that an in-memory substitute is never quite the database. mongodb-memory-server 2,853 (https://github.com/typegoose/mongodb-memory-server 2,853 ) (npm 2,036 i -D mongodb-memory-server) downloads a real mongod binary on first use and starts it on a random port against a temporary directory; a container is the only honest choice for transactions, indexes and aggregation pipelines. A database server is too slow to start per file, so boot it once in a global setup module exporting globalSetup (call MongoMemoryServer.create(), put mongod.getUri() into process.env) and globalTeardown, then run node --test --test-global-setup=./test/setup.mjs; that module runs once before the first test file and its teardown after the last. The flag is stability 1.0, early development. Never point a suite at a shared development database: a beforeEach that clears a collection is one environment variable away from clearing the wrong one. Express.js rebuilds these tests against Express 24,430 and MongoDB with Mongoose 243,355 models.