| 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 — no app.listen(), no port conflicts, no orphaned servers in CI.
- Export
app separately from the listener. The single biggest enabler: app.ts exports the Express app, server.ts calls listen(). Tests import app.ts only.
- Always
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.
- 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:
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;
}
import { createApp } from './app';
const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => console.log(`listening on :${port}`));
First test:
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));
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
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
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);
await request(app)
.post('/api/avatars')
.set('Authorization', `Bearer ${token}`)
.attach('avatar', 'test/fixtures/avatar.png')
.field('alt', 'profile picture')
.expect(201);
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
const agent = request.agent(app);
await agent
.post('/api/auth/login')
.send({ email: 'admin@example.com', password: 'test-password-123' })
.expect(200);
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-Type assertions as regex (/json/) — servers append ; charset=utf-8.
- Add a
jest.setup.ts that fails tests on unhandled promise rejections; SuperTest chains silently swallow them otherwise.
- For Fastify, call
await app.ready() before passing app.server to request().
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
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.
- Asserting entire bodies with
toEqual including timestamps and IDs. Use toMatchObject plus expect.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
supertest is in devDependencies, 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, missing await, 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.