Testing Patterns
Problem
Section titled “Problem”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.
Solution
Section titled “Solution”Server Setup with graceful()
Section titled “Server Setup with graceful()”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 createRouter, { graceful } from "@centralping/ergo-router";
describe("POST /users", () => { let shutdown;
after(async () => { if (shutdown) await shutdown("test-cleanup"); });
it("creates a user", async () => { const router = createRouter(); router.post("/users", { validate: { body: { type: "object", properties: { name: { type: "string" } }, required: ["name"], }, }, execute: (req, res, acc) => ({ response: { statusCode: 201, body: { name: acc.body.parsed.name }, }, }), });
const result = await graceful(router.handle(), { port: 0, exit() {}, }); shutdown = result.shutdown;
const { port } = result.server.address(); const 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"); });});Testing Authorization
Section titled “Testing Authorization”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 createRouter, { graceful } from "@centralping/ergo-router";
describe("authorization", () => { let shutdown; let baseUrl;
after(async () => { if (shutdown) await shutdown("test-cleanup"); });
it("accepts valid Bearer token", async () => { const router = createRouter(); router.get("/protected", { authorization: { strategies: [ { type: "Bearer", attributes: { realm: "API" }, authorizer: async (_attrs, token) => token === "valid-token" ? { authorized: true, info: { userId: "u1" } } : { authorized: false }, }, ], }, execute: (req, res, acc) => ({ response: { body: acc.auth } }), });
const result = await graceful(router.handle(), { port: 0, exit() {}, }); shutdown = result.shutdown;
const { port } = result.server.address(); baseUrl = `http://localhost:${port}`;
const res = await fetch(`${baseUrl}/protected`, { 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}/protected`, { 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}/protected`); assert.ok(res.status === 401 || res.status === 403); });});Testing Validation
Section titled “Testing Validation”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"); });});import { describe, it, after } from "node:test";import assert from "node:assert/strict";import createRouter, { graceful } from "@centralping/ergo-router";
describe("body validation", () => { let shutdown; let baseUrl;
after(async () => { if (shutdown) await shutdown("test-cleanup"); });
it("returns 422 for invalid body", async () => { const router = createRouter(); router.post("/users", { validate: { body: { type: "object", properties: { name: { type: "string" } }, required: ["name"], }, }, execute: (req, res, acc) => ({ response: { body: { name: acc.body.parsed.name } }, }), });
const result = await graceful(router.handle(), { port: 0, exit() {}, }); shutdown = result.shutdown;
const { port } = result.server.address(); baseUrl = `http://localhost:${port}`;
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("passes valid body through pipeline", async () => { 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"); });});Testing Rate Limiting
Section titled “Testing Rate Limiting”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"));});import { describe, it, after } from "node:test";import assert from "node:assert/strict";import createRouter, { graceful } from "@centralping/ergo-router";
describe("rate limiting", () => { let shutdown; let baseUrl;
after(async () => { if (shutdown) await shutdown("test-cleanup"); });
it("enforces rate limit", async () => { const router = createRouter({ transport: { rateLimit: { max: 5, windowMs: 60_000 } }, }); router.get("/limited", { execute: () => ({ response: { body: { ok: true } } }), });
const result = await graceful(router.handle(), { port: 0, exit() {}, }); shutdown = result.shutdown;
const { port } = result.server.address(); baseUrl = `http://localhost:${port}`;
const requests = Array.from({ length: 8 }, () => 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"); 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")); });});Testing Error Responses
Section titled “Testing Error Responses”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");});Testing Graceful Shutdown
Section titled “Testing Graceful Shutdown”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 createRouter, { graceful } from "@centralping/ergo-router";
describe("graceful shutdown", () => { it("stops accepting connections after shutdown", async () => { const router = createRouter(); router.get("/health", { execute: () => ({ response: { body: { ok: true } } }), });
const result = await graceful(router.handle(), { port: 0, signals: [], exit() {}, });
const { port } = result.server.address(); const baseUrl = `http://localhost:${port}`;
const res = await fetch(baseUrl + "/health"); assert.equal(res.status, 200);
await result.shutdown("test-cleanup");
await assert.rejects( () => fetch(baseUrl + "/health"), "should reject after shutdown", ); });
it("runs onShutdown callback during teardown", async () => { let cleanedUp = false;
const router = createRouter(); router.get("/", { execute: () => ({ response: { body: { ok: true } } }), });
const result = await graceful(router.handle(), { port: 0, signals: [], exit() {}, onShutdown: async () => { cleanedUp = true; }, });
await result.shutdown("test-cleanup"); assert.ok(cleanedUp, "onShutdown should have been called"); });});Testing Conditional Requests (ETag / 304)
Section titled “Testing Conditional Requests (ETag / 304)”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, ""); });});Test Isolation Tips
Section titled “Test Isolation Tips”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); });});Explanation
Section titled “Explanation”Server Lifecycle in Tests
Section titled “Server Lifecycle in Tests”The two tabs above illustrate the two server lifecycle strategies:
- Standalone — create an
http.Serverwithhandler(), listen on port0, and tear down withcloseAllConnections()+close()inafter(). - ergo-router — use
graceful()withport: 0andexit() {}.graceful()returns{ server, shutdown }. Callshutdown()inafter()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.
Ephemeral Ports
Section titled “Ephemeral Ports”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.
Graceful Shutdown in Tests
Section titled “Graceful Shutdown in Tests”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.
Black-Box Testing
Section titled “Black-Box Testing”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.
RFC 9457 Problem Details
Section titled “RFC 9457 Problem Details”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.
Rate-Limit Isolation
Section titled “Rate-Limit Isolation”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.
Conditional Request Round-Trip
Section titled “Conditional Request Round-Trip”send() generates ETags by default. To test conditional requests:
- Send an initial request and capture the
ETagheader - Send a second request with
If-None-Match: <etag> - Assert the response is
304 Not Modifiedwith an empty body
This pattern also works with Last-Modified / If-Modified-Since for
date-based conditional responses.
Test File Naming
Section titled “Test File Naming”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.