supertest-api-testing

star 148

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.

PramodDutta By PramodDutta schedule Updated 6/12/2026

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

  1. 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.
  2. 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.
  3. Always await (or return) the request chain. A SuperTest call is a thenable; forgetting await means the test passes before the request even fires.
  4. .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.
  5. 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.
  6. 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-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.
Install via CLI
npx skills add https://github.com/PramodDutta/qaskills --skill supertest-api-testing
Repository Details
star Stars 148
call_split Forks 15
navigation Branch main
article Path SKILL.md
More from Creator