The smallest useful load test: start samples/js-api, apply a short smoke of concurrent GETs, and fail the run if the error rate or p95 latency crosses a threshold. Compare k6 and Artillery on the same contract.
SUT: js-api · id: perf.http-smoke-load
Cached CI results from 10/3/2026, 10:14:14 AM (ci · d7784ca)
k6 is a Go binary that runs JS scripts: virtual users, http.get, check(), and thresholds that fail the process. The OSS CLI is enough here — no Grafana Cloud. Install via Homebrew or the CI setup-k6 action.
start-server-and-test boots samples/js-api. k6 run then spins VUs that issue real HTTP. Thresholds (error rate, p95/p99) are the assertions — if they breach, the exit code is non-zero, same as a failed unit test.
Testing architecture
Performance and load tests ask how the SUT behaves under concurrent HTTP, not whether one response matches a JSON fixture. samples/js-api is started on a real port; k6, Artillery, or Autocannon generate traffic; thresholds on errors and high-percentile latency fail the run. Duration stays short so CI stays cheap.
This is not a unit or integration assertion on one response. A load generator opens several virtual users against a real port, records latency and status codes, then evaluates thresholds. Duration stays short (~8s) so CI stays cheap — the idea is “does the handler survive a puff of traffic?”, not a soak.
SUT: js-api · samples/js-api/src/app.js · run npm test in examples/perf/http-smoke-load/k6
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/perf/http-smoke-load/k6/smoke.js · AGPL-3.0
· run in examples/perf/http-smoke-load/k6: npm test
// k6 smoke load — a few VUs, short duration, fail the process on thresholds
import http from "k6/http";
import { check, sleep } from "k6";
const BASE = __ENV.BASE_URL || "http://127.0.0.1:4180";
export const options = {
vus: 5,
duration: "8s",
thresholds: {
// Contract: almost no failed requests, p95 stays under half a second
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<500"],
},
};
export default function () {
const res = http.get(`${BASE}/items/1`);
check(res, {
"status is 200": (r) => r.status === 200,
"body is Notebook": (r) => r.json("name") === "Notebook",
});
sleep(0.2);
}
Needs the k6 binary on PATH (brew install k6; CI uses grafana/setup-k6-action).
Testing tool
Artillery · Node-friendly load test runner · MPL-2.0
Artillery describes load as YAML: a target, phases (arrival rate × duration), and a flow of HTTP steps. The open-source runner prints latency histograms and can fail the process via ensure thresholds.
start-server-and-test starts js-api. artillery run executes the YAML phase and writes a JSON report. A tiny run.mjs then fails the process if there are no 200s, any failed VUs, or p95 > 500ms — the twin of k6 thresholds.
Testing architecture
Performance and load tests ask how the SUT behaves under concurrent HTTP, not whether one response matches a JSON fixture. samples/js-api is started on a real port; k6, Artillery, or Autocannon generate traffic; thresholds on errors and high-percentile latency fail the run. Duration stays short so CI stays cheap.
This is not a unit or integration assertion on one response. A load generator opens several virtual users against a real port, records latency and status codes, then evaluates thresholds. Duration stays short (~8s) so CI stays cheap — the idea is “does the handler survive a puff of traffic?”, not a soak.
SUT: js-api · samples/js-api/src/app.js · run npm test in examples/perf/http-smoke-load/artillery
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/perf/http-smoke-load/artillery/smoke.yml · MPL-2.0
· run in examples/perf/http-smoke-load/artillery: npm test
# Artillery smoke — same GET /items/1 + p95 / error-rate contract as k6
# Thresholds are enforced by run.mjs after this YAML finishes.
config:
target: "http://127.0.0.1:4180"
phases:
- duration: 8
arrivalRate: 5
name: smoke
scenarios:
- name: get known item
flow:
- get:
url: "/items/1"
- think: 0.2