by thetestingacademy
HTTP assertion library for Node.js API testing with Express, Koa, and Fastify
npx @qaskills/cli add supertest-apiAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
This skill makes an AI agent write integration tests for Express/Koa/Fastify-compatible Node HTTP apps using SuperTest: pass the app object directly to request() so no port is bound, chain .expect() for status/header checks, and assert response bodies with Jest matchers. Trigger it when a Node project exposes an Express app, when the user asks to test REST endpoints without spinning up a server, or when supertest is already in devDependencies.
request(app) binds to an ephemeral port per request and tears it down — no app.listen(), no port conflicts, no orphaned servers in CI.app separately from the listener. The single biggest enabler: app.ts exports the Express app, server.ts calls listen(). Tests import app.ts only.await (or return) the request chain. A SuperTest call is a thenable; forgetting await means the test passes before the request even fires..expect(status) for transport, Jest matchers for payload. Status codes and content-type belong in the chain; body shape belongs in expect(res.body).toMatchObject(...) where failure diffs are readable.beforeEach), and make cleanup idempotent. Order-dependent suites rot within a sprint.npm install --save-dev supertest @types/supertest jest ts-jest @types/jest
The app/server split that makes everything testable:
// src/app.ts
import express from 'express';
import { usersRouter } from './routes/users';
export function createApp(): express.Express {
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
return app;
}
// src/server.ts — the ONLY file that listens; never imported by tests
import { createApp } from './app';
const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => console.log(`listening on :${port}`));
First test:
// src/app.test.ts
import request from 'supertest';
import { createApp } from './app';
const app = createApp();
describe('GET /health', () => {
it('responds 200 with status ok', async () => {
const res = await request(app)
.get('/health')
.expect('Content-Type', /json/)
.expect(200);
expect(res.body).toEqual({ status: 'ok' });
});
});
import request from 'supertest';
import { createApp } from './app';
import { resetDb } from '../test/helpers/db';
const app = createApp();
beforeEach(async () => {
await resetDb();
});
describe('POST /api/users', () => {
it('creates a user and returns 201 with the persisted record', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'mira@example.com', name: 'Mira' })
.expect(201);
expect(res.body).toMatchObject({
email: 'mira@example.com',
name: 'Mira',
});
expect(res.body.id).toEqual(expect.any(String));
// Round-trip: the created resource is retrievable
const fetched = await request(app).get(`/api/users/${res.body.id}`).expect(200);
expect(fetched.body.email).toBe('mira@example.com');
});
it('rejects an invalid email with 400 and a field-level error', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'Mira' })
.expect(400);
expect(res.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' }),
);
});
});
// test/helpers/auth.ts — log in once per suite, reuse the token
import request from 'supertest';
import type { Express } from 'express';
export async function getAuthToken(app: Express): Promise<string> {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@example.com', password: 'test-password-123' })
.expect(200);
return res.body.token as string;
}
import request from 'supertest';
import { createApp } from './app';
import { getAuthToken } from '../test/helpers/auth';
const app = createApp();
let token: string;
beforeAll(async () => {
token = await getAuthToken(app);
});
describe('DELETE /api/users/:id', () => {
it('returns 401 without a token', async () => {
await request(app).delete('/api/users/u_123').expect(401);
});
it('deletes with a valid bearer token', async () => {
await request(app)
.delete('/api/users/u_123')
.set('Authorization', `Bearer ${token}`)
.expect(204);
});
});
// Query strings via .query() — never hand-concatenate
const res = await request(app)
.get('/api/users')
.query({ page: 2, limit: 10, sort: 'createdAt' })
.expect(200);
expect(res.body.items).toHaveLength(10);
expect(res.body.page).toBe(2);
// multipart upload
await request(app)
.post('/api/avatars')
.set('Authorization', `Bearer ${token}`)
.attach('avatar', 'test/fixtures/avatar.png')
.field('alt', 'profile picture')
.expect(201);
// Function form of .expect() for response-wide invariants
await request(app)
.get('/api/users')
.expect(200)
.expect((response) => {
if (response.body.items.some((u: { password?: string }) => u.password)) {
throw new Error('password leaked in list endpoint');
}
});
// Persist cookies across requests with an agent
const agent = request.agent(app);
await agent
.post('/api/auth/login')
.send({ email: 'admin@example.com', password: 'test-password-123' })
.expect(200);
// agent carries the session cookie automatically
await agent.get('/api/me').expect(200);
'returns 409 when email already exists', not 'test create user 2'.jest --runInBand) or give each worker its own schema; parallel workers on one mutable DB produce heisenbugs.Content-Type assertions as regex (/json/) — servers append ; charset=utf-8.jest.setup.ts that fails tests on unhandled promise rejections; SuperTest chains silently swallow them otherwise.await app.ready() before passing app.server to request().app.listen() in test setup. Port collisions across Jest workers, orphan servers on failure. request(app) exists precisely so you never listen.await on the chain. The test exits green while the request is in flight. Enable @typescript-eslint/no-floating-promises to make this a lint error.toEqual including timestamps and IDs. Use toMatchObject plus expect.any(String) for generated fields; full-body equality breaks on every schema addition.supertest is in devDependencies, or the user asks to test Express/Koa/NestJS HTTP endpoints.app.listen, missing await, or shared mutable test data.- name: Install QA Skills
run: npx @qaskills/cli add supertest-api10 of 29 agents supported
Build AI agents that write, run, and fix tests. Playwright, LLM evals, and CI in one live cohort.
Use code AITESTER at checkout