Kodokon kodokon.com

Testing your API: node:test, fetch, and a test database

Test your routes end to end with node:test and fetch on an ephemeral port, each test getting its own in-memory SQLite database.

9 min · 3 questions

Open this lesson in Kodokon

Node 20 ships everything you need: node:test as the runner, node:assert/strict for assertions, global fetch as the HTTP client. Zero test dependencies. And this is where the architecture from the first lesson pays off: createApp({ db }) accepts an injected database, so each test can mount a complete, isolated API in a few milliseconds.

BASH
node --test
node --test --watch
The runner discovers the project's *.test.js files on its own.

To test the routes end to end, there's no need to mock Express: mount a real server on port 0 - the system assigns a free port - then hit it with fetch. You exercise exactly what a real client will see: routing, middlewares, serialization, status codes. It's the spirit of supertest, without the dependency.

JAVASCRIPT
import { once } from 'node:events';

export async function startTestServer(app) {
  const server = app.listen(0);
  await once(server, 'listening');
  const { port } = server.address();
  const url = `http://127.0.0.1:${port}`;
  const close = () =>
    new Promise((resolve) => {
      server.close(resolve);
    });
  return { url, close };
}
helpers.js: a real server on an OS-assigned port.

The test database: SQLite in memory (:memory:). Each test builds its own fresh, blank database via createDb, which applies the schema, then discards it on the way out - no data shared between tests, no cleanup script. t.after guarantees the server is closed even when an assertion fails.

JAVASCRIPT
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDb } from '../src/db/connection.js';
import { createApp } from '../src/app.js';
import { startTestServer } from './helpers.js';

test('POST /api/users creates a user', async (t) => {
  const db = createDb(':memory:');
  const app = createApp({ db });
  const server = await startTestServer(app);
  t.after(() => server.close());

  const res = await fetch(`${server.url}/api/users`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      email: 'ada@example.com',
      name: 'Ada',
    }),
  });

  assert.equal(res.status, 201);
  const body = await res.json();
  assert.equal(body.email, 'ada@example.com');
});
An end-to-end test: real HTTP stack, throwaway database.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why start the test server with app.listen(0)?
    • Port 0 disables the network and speeds up the tests
    • The system assigns a free port: parallel tests never fight over the same port
    • It's the only port allowed outside production
    • Port 0 forces Express into test mode
  2. What is the decisive advantage of a :memory: database created in each test?
    • It persists data between runs for comparison
    • Each test starts from a blank, isolated state, with no cleanup script and no interference between tests
    • It disables SQL constraints to simplify insertions
  3. Why close the server in t.after rather than at the end of the test body?
    • t.after runs even if an assertion fails, avoiding orphan servers that keep the process from exiting
    • t.after runs before the test, which prepares the server
    • It's purely stylistic, the behavior is identical