Skip to content

Testing Patterns

You need to write reliable, non-flaky HTTP tests for ergo APIs that start and stop servers cleanly, use ephemeral ports to avoid conflicts, and test behavior through the full HTTP stack — including auth, validation, error responses, conditional requests, rate limiting, and graceful shutdown.

import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { handler, compose, body } from "@centralping/ergo";
describe("POST /users", () => {
let server;
let baseUrl;
after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
it("creates a user", async () => {
const pipeline = compose(
{ fn: body(), setPath: "body" },
(req, res, acc) => ({
response: {
statusCode: 201,
body: { name: acc.body.parsed.name },
},
}),
);
server = http.createServer(handler(pipeline));
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
baseUrl = `http://localhost:${port}`;
const res = await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Jane" }),
});
assert.equal(res.status, 201);
const data = await res.json();
assert.equal(data.name, "Jane");
});
});
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import {
handler,
compose,
authorization,
} from "@centralping/ergo";
describe("authorization", () => {
let server;
let baseUrl;
after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
it("accepts valid Bearer token", async () => {
const pipeline = compose(
{
fn: authorization({
strategies: [
{
type: "Bearer",
attributes: { realm: "API" },
authorizer: async (_attrs, token) =>
token === "valid-token"
? { authorized: true, info: { userId: "u1" } }
: { authorized: false },
},
],
}),
setPath: "auth",
},
(req, res, acc) => ({ response: { body: acc.auth } }),
);
server = http.createServer(handler(pipeline));
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
baseUrl = `http://localhost:${port}`;
const res = await fetch(baseUrl, {
headers: { Authorization: "Bearer valid-token" },
});
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.userId, "u1");
});
it("rejects invalid token with 401 + WWW-Authenticate", async () => {
const res = await fetch(baseUrl, {
headers: { Authorization: "Bearer wrong-token" },
});
assert.equal(res.status, 401);
assert.ok(
res.headers.get("www-authenticate"),
"should include WWW-Authenticate header",
);
});
it("rejects missing Authorization with 401", async () => {
const res = await fetch(baseUrl);
assert.ok(res.status === 401 || res.status === 403);
});
});
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { handler, compose, body, validate } from "@centralping/ergo";
describe("body validation", () => {
let server;
let baseUrl;
after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
it("returns 422 for invalid body", async () => {
const schema = {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
};
const pipeline = compose(
{ fn: body(), setPath: "body" },
validate({ body: schema }),
(req, res, acc) => ({
response: { body: { name: acc.body.parsed.name } },
}),
);
server = http.createServer(handler(pipeline));
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
baseUrl = `http://localhost:${port}`;
const res = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: 42 }),
});
assert.equal(res.status, 422);
const problem = await res.json();
assert.equal(problem.status, 422);
assert.equal(problem.title, "Unprocessable Entity");
assert.ok(problem.detail);
});
it("passes valid body through pipeline", async () => {
const res = await fetch(baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Jane" }),
});
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.name, "Jane");
});
});
it("enforces rate limit", async () => {
const requests = Array.from({ length: 12 }, () =>
fetch(`${baseUrl}/limited`),
);
const responses = await Promise.all(requests);
const tooMany = responses.filter((r) => r.status === 429);
assert.ok(tooMany.length > 0, "Expected at least one 429 response");
const problem = await tooMany[0].json();
assert.equal(problem.status, 429);
assert.ok(problem.retryAfter != null);
});
it("includes rate-limit headers on every response", async () => {
const res = await fetch(`${baseUrl}/limited`);
assert.ok(res.headers.get("x-ratelimit-limit"));
assert.ok(res.headers.get("x-ratelimit-remaining"));
assert.ok(res.headers.get("x-ratelimit-reset"));
});
it("returns 422 for invalid body", async () => {
const res = await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: 42 }),
});
assert.equal(res.status, 422);
const problem = await res.json();
assert.equal(problem.status, 422);
assert.equal(problem.title, "Unprocessable Entity");
assert.ok(problem.detail);
});
it("includes request ID in error response", async () => {
const res = await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: 42 }),
});
const problem = await res.json();
assert.ok(problem.instance, "should include request ID as instance");
});
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { handler } from "@centralping/ergo";
describe("shutdown", () => {
it("stops accepting connections after close", async () => {
const pipeline = () => ({ response: { body: { ok: true } } });
const server = http.createServer(handler(pipeline));
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const baseUrl = `http://localhost:${port}`;
const res = await fetch(baseUrl);
assert.equal(res.status, 200);
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await assert.rejects(
() => fetch(baseUrl),
"should reject after server close",
);
});
});
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { handler } from "@centralping/ergo";
describe("ETag conditional requests", () => {
let server;
let baseUrl;
after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
it("generates ETag and returns 304 on match", async () => {
const pipeline = () => ({
response: { body: { message: "hello", value: 42 } },
});
server = http.createServer(handler(pipeline));
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
baseUrl = `http://localhost:${port}`;
const res1 = await fetch(baseUrl);
assert.equal(res1.status, 200);
const etag = res1.headers.get("etag");
assert.ok(etag, "should include ETag header");
const res2 = await fetch(baseUrl, {
headers: { "If-None-Match": etag },
});
assert.equal(res2.status, 304);
const body = await res2.text();
assert.equal(body, "");
});
});
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
import createRouter, { graceful } from "@centralping/ergo-router";
describe("isolated test suites", () => {
let shutdown;
after(async () => {
if (shutdown) await shutdown("test-cleanup");
});
it("each suite gets its own server and state", async () => {
const router = createRouter({
transport: { rateLimit: { max: 100, windowMs: 60_000 } },
});
router.get("/", {
execute: () => ({ response: { body: { ok: true } } }),
});
const result = await graceful(router.handle(), {
port: 0,
exit() {},
});
shutdown = result.shutdown;
const { port } = result.server.address();
const res = await fetch(`http://localhost:${port}/`);
assert.equal(res.status, 200);
});
});

The two tabs above illustrate the two server lifecycle strategies:

  • Standalone — create an http.Server with handler(), listen on port 0, and tear down with closeAllConnections() + close() in after().
  • ergo-router — use graceful() with port: 0 and exit() {}. graceful() returns { server, shutdown }. Call shutdown() in after() to trigger the same drain sequence the production server uses.

Both approaches produce an ephemeral baseUrl for fetch() requests and clean up connections in test teardown.

Passing 0 to server.listen() or graceful() lets the OS assign an unused port. This eliminates port conflicts when tests run in parallel or on CI servers. Retrieve the assigned port from server.address().port.

Always call server.closeAllConnections() before server.close() in test teardown. Without closeAllConnections(), keep-alive connections from fetch() can hold the server open, causing test hangs. This pattern is used throughout ergo’s own functional test suite.

When using graceful(), pass exit() {} to prevent process.exit() from killing the test runner. Pass signals: [] if you do not want signal handlers registered during tests.

Test through the HTTP interface using fetch() (built into Node.js 22+, no polyfill needed). This validates the full middleware pipeline — content negotiation, body parsing, validation, auth, and response serialization — without coupling tests to internal middleware structure.

All ergo error responses follow RFC 9457 format with type, title, status, and detail fields. Assert on these structured fields rather than parsing error message strings — the format is stable across all ergo middleware.

Each createRouter() creates a fresh transport stack with its own MemoryStore for rate limiting. No explicit store reset is needed between test suites — just create a new router per suite.

send() generates ETags by default. To test conditional requests:

  1. Send an initial request and capture the ETag header
  2. Send a second request with If-None-Match: <etag>
  3. Assert the response is 304 Not Modified with an empty body

This pattern also works with Last-Modified / If-Modified-Since for date-based conditional responses.

ergo uses a two-tier test naming convention:

  • *.spec.unit.js — unit/boundary tests (no HTTP server)
  • *.spec.func.js — functional/contract tests (real HTTP server + fetch)

Follow the same convention for application tests to keep the test surface organized.