name: SuperTest API Testing description: Test Node.js HTTP APIs in-process with SuperTest — request(app) without binding a port, chained .expect assertions, auth headers, JSON body validation, and Jest integration with proper async/await patterns. version: 1.0.0 author: thetestingacademy license: MIT tags: [supertest, api-testing, express, nodejs, jest, integration-testing, http, rest, assertions] testingTypes: [api, integration] frameworks: [jest, supertest, express] languages: [typescript, javascript] domains: [api] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp]
SuperTest API Testing
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.
Core Principles
- Test the app object, not a running server.
request(app)binds to an ephemeral port per request and tears it down — noapp.listen(), no port conflicts, no orphaned servers in CI. - Export
appseparately from the listener. The single biggest enabler:app.tsexports the Express app,server.tscallslisten(). Tests importapp.tsonly. - Always
await(or return) the request chain. A SuperTest call is a thenable; forgettingawaitmeans 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 inexpect(res.body).toMatchObject(...)where failure diffs are readable.- Real database or none — never half-mocked. Either run integration tests against a disposable database (Testcontainers, SQLite in-memory) or mock the data layer entirely. Mocking two of five queries gives you tests that lie.
- Each test owns its data. Create the records a test needs inside the test (or a
beforeEach), and make cleanup idempotent. Order-dependent suites rot within a sprint.
Setup
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' });
});
});
Patterns
CRUD Round-Trip with Body Assertions
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' }),
);
});
});
Authenticated Requests
// 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 Params, File Upload, and Custom Assertions
// 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');
}
});
Cookies and Sessions
// 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);
Best Practices
- Name tests by behavior and status:
'returns 409 when email already exists', not'test create user 2'. - Cover the unhappy paths the framework will not: malformed JSON body, missing auth, wrong content-type, oversized payload, nonexistent IDs (404 vs 400 for invalid format).
- Run integration tests serially against a shared DB (
jest --runInBand) or give each worker its own schema; parallel workers on one mutable DB produce heisenbugs. - Keep
Content-Typeassertions as regex (/json/) — servers append; charset=utf-8. - Add a
jest.setup.tsthat fails tests on unhandled promise rejections; SuperTest chains silently swallow them otherwise. - For Fastify, call
await app.ready()before passingapp.servertorequest().
Anti-Patterns
app.listen()in test setup. Port collisions across Jest workers, orphan servers on failure.request(app)exists precisely so you never listen.- Forgetting
awaiton the chain. The test exits green while the request is in flight. Enable@typescript-eslint/no-floating-promisesto make this a lint error. - Asserting entire bodies with
toEqualincluding timestamps and IDs. UsetoMatchObjectplusexpect.any(String)for generated fields; full-body equality breaks on every schema addition. - One mega-test that exercises login, create, update, and delete. When it fails at step 14 you debug all 14 steps. Split per behavior, share setup via helpers.
- Seeding through raw SQL while testing through HTTP. Your seed bypasses validation and hashing; create test data through the API or through the same repository layer the app uses.
- Testing third-party middleware (body-parser limits, cors echo) — pin your config in one test if you must, but do not re-test Express itself.
When to Trigger This Skill
supertestis indevDependencies, or the user asks to test Express/Koa/NestJS HTTP endpoints.- An API has no integration tests and the user wants coverage without deploying or binding ports.
- Reviewing failing or flaky API tests that use
app.listen, missingawait, or shared mutable test data. - The user asks how to test auth-protected routes, file uploads, or cookie sessions in Node.
- Setting up the app/server split so an existing Express codebase becomes testable.