Supertest API Integration Testing with TypeScript — 2026 Guide

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why Integration Tests Matter

Unit tests verify logic in isolation. Integration tests verify that your routes, middleware, validation, business logic, and database work together correctly. Supertest fires real HTTP requests against your Express app in-process — no running server required — making integration tests fast and reliable.

A well-tested API has: unit tests for business logic, integration tests for HTTP layer, and E2E tests for critical user flows.

Installation

npm install --save-dev supertest @types/supertest vitest @vitest/coverage-v8

App Setup (Testable Pattern)

The key to testable Express apps is exporting the app object separately from listen():

// src/app.ts — export app without starting server
import express from 'express';
import { router } from './routes';
 
export const app = express();
app.use(express.json());
app.use('/api', router);
 
// Global error handler
app.use((err: Error, _req: any, res: any, _next: any) => {
  console.error(err);
  res.status(500).json({ error: err.message });
});
 
// src/server.ts — start server separately
import { app } from './app';
app.listen(3000, () => console.log('Server running'));

Writing Integration Tests

// src/routes/users.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import supertest from 'supertest';
import { app } from '../app';
import { db } from '../db';
 
const request = supertest(app);
 
describe('Users API', () => {
  beforeAll(async () => {
    await db.migrate.latest();   // run migrations
    await db.seed.run();         // seed test data
  });
 
  afterAll(async () => {
    await db.destroy();          // close connection pool
  });
 
  beforeEach(async () => {
    await db('users').truncate(); // fresh state per test
  });
 
  describe('GET /api/users', () => {
    it('returns empty array when no users', async () => {
      const res = await request.get('/api/users').expect(200);
      expect(res.body).toEqual({ data: [], meta: { total: 0, page: 1 } });
    });
 
    it('returns paginated users', async () => {
      await db('users').insert([
        { name: 'Alice', email: 'alice@example.com' },
        { name: 'Bob',   email: 'bob@example.com'   },
      ]);
 
      const res = await request
        .get('/api/users')
        .query({ page: 1, limit: 10 })
        .expect(200);
 
      expect(res.body.data).toHaveLength(2);
      expect(res.body.meta.total).toBe(2);
    });
  });
 
  describe('POST /api/users', () => {
    it('creates a user with valid data', async () => {
      const res = await request
        .post('/api/users')
        .send({ name: 'Alice', email: 'alice@example.com', password: 'secure123' })
        .expect(201);
 
      expect(res.body.user).toMatchObject({ name: 'Alice', email: 'alice@example.com' });
      expect(res.body.user.id).toBeDefined();
      expect(res.body.user.password).toBeUndefined(); // password not exposed
    });
 
    it('returns 422 for invalid email', async () => {
      const res = await request
        .post('/api/users')
        .send({ name: 'Alice', email: 'not-an-email', password: 'secure123' })
        .expect(422);
 
      expect(res.body.errors.email).toBeDefined();
    });
 
    it('returns 409 for duplicate email', async () => {
      await db('users').insert({ name: 'Alice', email: 'alice@example.com' });
 
      await request
        .post('/api/users')
        .send({ name: 'Bob', email: 'alice@example.com', password: 'secure123' })
        .expect(409);
    });
  });
});

Testing Authenticated Routes

import jwt from 'jsonwebtoken';
 
function authToken(userId: number, role = 'user'): string {
  return jwt.sign(
    { userId, role },
    process.env.JWT_SECRET ?? 'test-secret',
    { expiresIn: '1h' }
  );
}
 
describe('Protected routes', () => {
  it('returns 401 without token', async () => {
    await request.get('/api/profile').expect(401);
  });
 
  it('returns profile for authenticated user', async () => {
    const [userId] = await db('users').insert({ name: 'Alice', email: 'a@b.com' });
    const token = authToken(userId);
 
    const res = await request
      .get('/api/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);
 
    expect(res.body.user.email).toBe('a@b.com');
  });
 
  it('returns 403 for non-admin on admin route', async () => {
    const token = authToken(1, 'user');
    await request
      .delete('/api/admin/users/1')
      .set('Authorization', `Bearer ${token}`)
      .expect(403);
  });
});

Testing File Uploads

import path from 'path';
 
describe('File upload', () => {
  it('accepts valid image upload', async () => {
    const res = await request
      .post('/api/upload')
      .set('Authorization', `Bearer ${authToken(1)}`)
      .attach('file', path.join(__dirname, 'fixtures/test-image.jpg'))
      .expect(200);
 
    expect(res.body.url).toMatch(/https?:\/\//);
  });
 
  it('rejects files over 5MB', async () => {
    await request
      .post('/api/upload')
      .attach('file', path.join(__dirname, 'fixtures/large-file.pdf'))
      .expect(413);
  });
});

Test Database Strategy

// Option 1 — SQLite in-memory for fast tests
import Database from 'better-sqlite3';
 
const testDb = new Database(':memory:');
 
// Option 2 — PostgreSQL test database with transactions
// Start transaction before each test, rollback after
// Prevents data from persisting between tests without truncation
 
// Option 3 — Test containers (recommended for production parity)
import { PostgreSqlContainer } from '@testcontainers/postgresql';
 
let container: any;
 
beforeAll(async () => {
  container = await new PostgreSqlContainer().start();
  process.env.DATABASE_URL = container.getConnectionUri();
  await runMigrations();
});
 
afterAll(async () => {
  await container.stop();
});

Common Mistakes

  • Importing app after calling app.listen() — starts a real server during tests, causing port conflicts
  • Not resetting database state between tests — test A's data leaks into test B, causing flaky failures
  • Using process.env.NODE_ENV checks that skip middleware in test env — tests miss behavior that exists in production
  • Testing a new app instance per test file without a shared connection — exhausts database connection pools
  • Writing assertions only on status codes — also assert response body shape for meaningful test coverage

Best Practices

  • Export app separately from listen() so Supertest can attach without binding a port
  • Use a dedicated test database or in-memory SQLite — never run integration tests against production
  • Reset (truncate/rollback) database tables in beforeEach, not afterAll — ensures clean state
  • Generate JWT tokens inline using the same secret as the app — do not hardcode fixture tokens
  • Run integration tests in CI against a real database (Docker/test containers) for production parity

Key Takeaways

  • Supertest makes HTTP requests to your Express app in-process — no running server or random ports
  • Export app from app.ts and listen() from server.ts — the golden rule for testable Express apps
  • Integration tests verify the full stack: routing, middleware, validation, business logic, and database
  • Use beforeEach truncation for test isolation — not afterAll cleanup which can miss failures
  • Generate real JWT tokens in tests using jsonwebtoken.sign() — avoid hardcoded fixture tokens
  • Supertest chains .set(), .send(), .query(), .attach() for full request control
  • Test all response shapes, not just status codes — verify error bodies contain errors.fieldName
  • Use Testcontainers for PostgreSQL/MySQL in CI to achieve production-parity database testing

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading