Call GET /items/999 on the same Express app and assert status 404 plus { error, id }. Compare how Supertest, Playwright request, and pytest + httpx check an HTTP error contract.
SUT: js-api · id: integration.error-contract
Cached CI results from 10/3/2026, 10:14:14 AM (ci · d7784ca)
Supertest · HTTP integration helper on top of a JS runner · MIT
Supertest drives an Express/Connect/Fastify app without you opening a public port. You pass the app instance; SuperTest injects requests through the middleware stack and exposes .get/.post plus status and body.
This repo runs SuperTest inside Vitest. createApp() returns Express; request(app).get(...) walks routing and JSON handlers in-process. That is still an integration test (full HTTP stack) but cheaper than bind + curl.
Testing architecture
Integration tests cross a real boundary — here, HTTP. The Express app in samples/js-api is the SUT. Variants either inject requests in-process (SuperTest) or speak TCP to an ephemeral port (Playwright request, pytest + httpx). Assertions target status codes and JSON contracts, not private helpers.
Same HTTP integration shape as GET success, but the contract is the error: 404 and { error: "not_found", id: "999" }. Clients must not treat a missing item as an empty 200. The app is the only source of that JSON; tests do not stub the handler.
SUT: js-api · samples/js-api/src/app.js · run npm test in examples/integration/error-contract/supertest
Code under test · samples/js-api/src/app.js
import express from "express";
/** In-memory catalog — no DB so integration tests stay local and cheap. */
const ITEMS = new Map([["1", { id: "1", name: "Notebook" }]]);
/**
* Build the Express app (no listen). Tests import this and bind a port themselves.
*/
export function createApp() {
const app = express();
// Parse JSON bodies if a later POST example needs them
app.use(express.json());
// Baseline browser-isolation headers — asserted by security.http-headers
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
next();
});
// Liveness probe — integration + load/microbench examples hit this
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// Read one item by id from the in-memory store
app.get("/items/:id", (req, res) => {
const item = ITEMS.get(req.params.id);
if (!item) {
// Stable error contract: same JSON shape for every missing id
res.status(404).json({ error: "not_found", id: req.params.id });
return;
}
res.json(item);
});
return app;
}
Test · examples/integration/error-contract/supertest/not-found.test.js · MIT
· run in examples/integration/error-contract/supertest: npm test
// Vitest runner + SuperTest HTTP assertions against Express
import { describe, it, expect } from "vitest";
import request from "supertest";
// Import the app factory — SuperTest drives it in-process (no real port)
import { createApp } from "../../../../samples/js-api/src/app.js";
describe("GET /items/:id missing", () => {
it("returns the not_found JSON contract", async () => {
const app = createApp();
// SuperTest issues a real HTTP request through the Express stack
const res = await request(app).get("/items/999");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "not_found", id: "999" });
});
});
Playwright is best known for driving Chromium/Firefox/WebKit. It also ships APIRequest: a first-party HTTP client with the same expect() matchers. These scenarios launch no browser; they only use request.newContext().
beforeAll binds createApp() to listen(0). Each test opens an APIRequest context aimed at that base URL, issues GET, and asserts status + JSON. afterAll closes the server. Same contract as SuperTest, over a real port.
Testing architecture
Integration tests cross a real boundary — here, HTTP. The Express app in samples/js-api is the SUT. Variants either inject requests in-process (SuperTest) or speak TCP to an ephemeral port (Playwright request, pytest + httpx). Assertions target status codes and JSON contracts, not private helpers.
Same HTTP integration shape as GET success, but the contract is the error: 404 and { error: "not_found", id: "999" }. Clients must not treat a missing item as an empty 200. The app is the only source of that JSON; tests do not stub the handler.
SUT: js-api · samples/js-api/src/app.js · run npm test in examples/integration/error-contract/playwright
Code under test · samples/js-api/src/app.js
import express from "express";
/** In-memory catalog — no DB so integration tests stay local and cheap. */
const ITEMS = new Map([["1", { id: "1", name: "Notebook" }]]);
/**
* Build the Express app (no listen). Tests import this and bind a port themselves.
*/
export function createApp() {
const app = express();
// Parse JSON bodies if a later POST example needs them
app.use(express.json());
// Baseline browser-isolation headers — asserted by security.http-headers
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
next();
});
// Liveness probe — integration + load/microbench examples hit this
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// Read one item by id from the in-memory store
app.get("/items/:id", (req, res) => {
const item = ITEMS.get(req.params.id);
if (!item) {
// Stable error contract: same JSON shape for every missing id
res.status(404).json({ error: "not_found", id: req.params.id });
return;
}
res.json(item);
});
return app;
}
Test · examples/integration/error-contract/playwright/not-found.spec.js · Apache-2.0
· run in examples/integration/error-contract/playwright: npm test
// Playwright request API (no page / no browser)
import { test, expect, request as playwrightRequest } from "@playwright/test";
import { createApp } from "../../../../samples/js-api/src/app.js";
let server;
let baseURL;
test.beforeAll(async () => {
const app = createApp();
// listen(0) lets the OS pick a free port
server = await new Promise((resolve) => {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
});
const { port } = server.address();
baseURL = `http://127.0.0.1:${port}`;
});
test.afterAll(async () => {
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
});
test("GET /items/999 returns the not_found JSON contract", async () => {
// Fresh APIRequest context pointed at the ephemeral server
const context = await playwrightRequest.newContext({ baseURL });
const res = await context.get("/items/999");
expect(res.status()).toBe(404);
expect(await res.json()).toEqual({ error: "not_found", id: "999" });
await context.dispose();
});
Uses Playwright's APIRequest context only — no browser is launched.
Testing tool
pytest + httpx · Python HTTP client used under pytest · MIT
httpx is a modern Python HTTP client (requests-like API, HTTP/2 capable). Combined with pytest it is the Python twin of SuperTest / Playwright request: a real GET/POST against a running server, then assert on status and JSON.
pytest starts a fixture that spawns samples/js-api on an ephemeral port (Node script prints the base URL). httpx then issues a real TCP request to that process. After the test, the fixture terminates the server.
Testing architecture
Integration tests cross a real boundary — here, HTTP. The Express app in samples/js-api is the SUT. Variants either inject requests in-process (SuperTest) or speak TCP to an ephemeral port (Playwright request, pytest + httpx). Assertions target status codes and JSON contracts, not private helpers.
Same HTTP integration shape as GET success, but the contract is the error: 404 and { error: "not_found", id: "999" }. Clients must not treat a missing item as an empty 200. The app is the only source of that JSON; tests do not stub the handler.
SUT: js-api · samples/js-api/src/app.js · run pytest in examples/integration/error-contract/pytest
Code under test · samples/js-api/src/app.js
import express from "express";
/** In-memory catalog — no DB so integration tests stay local and cheap. */
const ITEMS = new Map([["1", { id: "1", name: "Notebook" }]]);
/**
* Build the Express app (no listen). Tests import this and bind a port themselves.
*/
export function createApp() {
const app = express();
// Parse JSON bodies if a later POST example needs them
app.use(express.json());
// Baseline browser-isolation headers — asserted by security.http-headers
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
next();
});
// Liveness probe — integration + load/microbench examples hit this
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// Read one item by id from the in-memory store
app.get("/items/:id", (req, res) => {
const item = ITEMS.get(req.params.id);
if (!item) {
// Stable error contract: same JSON shape for every missing id
res.status(404).json({ error: "not_found", id: req.params.id });
return;
}
res.json(item);
});
return app;
}
Test · examples/integration/error-contract/pytest/test_not_found.py · MIT
· run in examples/integration/error-contract/pytest: pytest
# Spawn the Node API, then GET a missing id with httpx
import sys
from pathlib import Path
import httpx
import pytest
# Import the shared spawn helper from examples/integration/
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from _node_api import start_api, stop_api
@pytest.fixture()
def base_url():
# Start Express on an ephemeral port; first stdout line is the URL
proc, url = start_api()
yield url
stop_api(proc)
def test_missing_item_error_contract(base_url):
# Real HTTP GET — expect the documented 404 JSON shape
res = httpx.get(f"{base_url}/items/999")
assert res.status_code == 404
assert res.json() == {"error": "not_found", "id": "999"}
Spawns samples/js-api/scripts/serve-ephemeral.mjs so httpx talks to the same Express app.