Skip to content

Fix: Supertest Not Working — Requests Not Sending, Server Not Closing, or Assertions Failing

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Supertest HTTP testing issues — Express and Fastify setup, async test patterns, authentication headers, file uploads, JSON body assertions, and Vitest/Jest integration.

The Problem

Supertest requests hang and tests time out:

import request from 'supertest';
import app from './app';

test('GET /api/users', async () => {
  const res = await request(app).get('/api/users');
  // Test hangs — never resolves
});

Or the server port is already in use:

Error: listen EADDRINUSE: address already in use :::3000

Or assertions pass locally but fail in CI:

Expected status 200, received 500

Why This Happens

Supertest creates an HTTP server from your Express app and sends requests to it. Common issues:

  • The app must not call listen() — Supertest binds the app to a random ephemeral port internally. If your app already calls app.listen(3000), you get EADDRINUSE. Export the Express app separately from the server start.
  • Async middleware or database connections delay responses — if your app connects to a database on startup and the connection isn’t established when tests run, requests hang or return 500.
  • The server isn’t closed between tests — Supertest creates a new server for each request(app) call. Without proper cleanup, servers accumulate and ports get exhausted.
  • Request bodies need the right Content-Type.send({ name: 'Alice' }) sets Content-Type: application/json automatically, but .send('raw string') doesn’t. If your Express app expects JSON but receives text, parsing fails.

The hang-and-timeout pattern usually points to a missing next() call or an unhandled promise rejection in middleware. Express’s request lifecycle requires every middleware to either send a response (res.json, res.send, res.end) or call next() to pass control. If an async middleware throws and the error isn’t caught, the request never terminates. Supertest waits, the test framework times out, and you get a useless “exceeded 5000ms” error. The real fix is app.use((err, req, res, next) => res.status(500).json({ error: err.message })) at the end of the middleware chain — Express only treats four-argument middleware as an error handler.

The EADDRINUSE error is almost always a code-organization problem. If src/index.ts both imports app and calls app.listen(), then any test that imports anything from src/index.ts triggers the listen call. The convention that solves this is to separate src/app.ts (creates and exports the Express app, no listen) from src/server.ts (imports app and calls listen). Tests import app.ts; production imports server.ts. This split is worth enforcing with a lint rule.

The “passes locally, fails in CI” case is the slow killer. Local environments have warm database connections, cached DNS, pre-populated test data, and TLS certificates that already pass validation. CI starts from zero on each run. A test that works locally because your users table happens to contain a row from yesterday’s manual testing will fail in CI where the table is empty. The fix is to make every test self-contained: seed the data it needs in beforeEach, clean up in afterEach, and never depend on test order. If a test only works because a previous test set up its state, it’s a flake waiting to happen.

Production Incident Lens: When Tests Don’t Catch Real Bugs

The blast radius of broken integration tests is dangerous because it’s invisible. A test suite that hangs and times out is annoying but obvious. A test suite that passes against an incomplete or wrong mock is silently destructive: it gives the team confidence to deploy code that fails the moment it hits production traffic. False security is worse than no security.

The patterns to watch for:

  1. Tests that mock the database with jest.mock('./db') — these tests verify that your code calls the mock the way you expect. They do not verify that the SQL is valid, that the schema matches, or that your transaction boundaries are correct. A test suite that’s 100% mocks is a test suite that catches nothing real.
  2. Tests that always return 200 because the error path is mocked out — a Supertest call to /api/users that returns 200 in tests but throws in production usually means the test mocked the error path away. The route handler is fine; the integration is broken.
  3. Tests that depend on a hand-rolled Express app instance inside each test file — different test files create different app instances with different middleware orders. A bug that depends on the order of cors and helmet middleware is invisible to tests that bypass production’s middleware stack.

The on-call recovery for “tests passed but production broke” usually involves three steps. First, reproduce the production failure with a Supertest test that hits the real app with the real middleware. Second, add that test to CI so the regression can’t ship again. Third, audit the rest of the suite for the same pattern. If one route had a missing integration test, the others probably do too. Treat the failure as a signal about test coverage philosophy, not just a single bug.

The deeper SRE principle: integration tests should test integration. If your route depends on Postgres, run Postgres in CI (Docker, testcontainers, or a managed test database). If it depends on Redis, run Redis. Mocking is for cross-service boundaries you don’t control, not for the database your own service owns.

Fix 1: Separate App from Server

// src/app.ts — export the Express app (no listen)
import express from 'express';

const app = express();
app.use(express.json());

app.get('/api/users', async (req, res) => {
  const users = await db.query.users.findMany();
  res.json(users);
});

app.post('/api/users', async (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email required' });
  }
  const user = await db.insert(users).values({ name, email }).returning();
  res.status(201).json(user[0]);
});

export default app;
// src/server.ts — start the server (not imported in tests)
import app from './app';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
// tests/api.test.ts — test using the app, not the server
import request from 'supertest';
import app from '../src/app';

describe('Users API', () => {
  test('GET /api/users returns user list', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect('Content-Type', /json/)
      .expect(200);

    expect(res.body).toBeInstanceOf(Array);
  });

  test('POST /api/users creates a user', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: '[email protected]' })
      .expect(201);

    expect(res.body).toMatchObject({
      name: 'Alice',
      email: '[email protected]',
    });
    expect(res.body.id).toBeDefined();
  });

  test('POST /api/users validates input', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '' })  // Missing email
      .expect(400);

    expect(res.body.error).toBe('Name and email required');
  });
});

Fix 2: Authentication Testing

import request from 'supertest';
import app from '../src/app';

describe('Protected Routes', () => {
  let authToken: string;

  beforeAll(async () => {
    // Login to get a token
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'password123' });

    authToken = res.body.token;
  });

  test('GET /api/profile returns user profile', async () => {
    const res = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200);

    expect(res.body.email).toBe('[email protected]');
  });

  test('GET /api/profile rejects without token', async () => {
    await request(app)
      .get('/api/profile')
      .expect(401);
  });

  // Custom headers
  test('API key authentication', async () => {
    await request(app)
      .get('/api/data')
      .set('X-API-Key', 'test-api-key')
      .expect(200);
  });

  // Cookie-based auth
  test('Cookie authentication', async () => {
    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'pass' });

    // Extract cookie from login response
    const cookie = loginRes.headers['set-cookie'];

    await request(app)
      .get('/api/profile')
      .set('Cookie', cookie)
      .expect(200);
  });
});

Fix 3: Request Types

import request from 'supertest';
import app from '../src/app';
import path from 'path';

// JSON body
test('POST JSON', async () => {
  await request(app)
    .post('/api/posts')
    .send({ title: 'Hello', body: 'World' })  // Auto sets Content-Type: application/json
    .expect(201);
});

// Form data
test('POST form data', async () => {
  await request(app)
    .post('/api/contact')
    .type('form')
    .send('name=Alice&message=Hello')
    .expect(200);
});

// File upload (multipart)
test('POST file upload', async () => {
  const res = await request(app)
    .post('/api/upload')
    .attach('file', path.join(__dirname, 'fixtures/test-image.jpg'))
    .field('description', 'Test image')
    .expect(201);

  expect(res.body.filename).toMatch(/\.jpg$/);
  expect(res.body.size).toBeGreaterThan(0);
});

// Multiple files
test('POST multiple files', async () => {
  await request(app)
    .post('/api/upload/batch')
    .attach('files', 'tests/fixtures/file1.pdf')
    .attach('files', 'tests/fixtures/file2.pdf')
    .expect(201);
});

// Query parameters
test('GET with query params', async () => {
  const res = await request(app)
    .get('/api/search')
    .query({ q: 'typescript', page: 1, limit: 10 })
    .expect(200);

  expect(res.body.results).toHaveLength(10);
});

// Custom Content-Type
test('POST XML', async () => {
  await request(app)
    .post('/api/webhook')
    .set('Content-Type', 'application/xml')
    .send('<event><type>order.created</type></event>')
    .expect(200);
});

Fix 4: Response Assertions

import request from 'supertest';
import app from '../src/app';

test('detailed response assertions', async () => {
  const res = await request(app)
    .get('/api/users/123')
    .expect(200)
    .expect('Content-Type', /json/)          // Regex match on header
    .expect('Cache-Control', 'no-store')      // Exact header match
    .expect(res => {
      // Custom assertion function
      if (!res.body.id) throw new Error('Missing id');
      if (res.body.role !== 'admin') throw new Error('Expected admin role');
    });

  // Additional assertions with your test framework
  expect(res.body).toEqual({
    id: '123',
    name: expect.any(String),
    email: expect.stringContaining('@'),
    role: 'admin',
    createdAt: expect.any(String),
  });

  // Check response headers
  expect(res.headers['x-request-id']).toBeDefined();

  // Check status text
  expect(res.status).toBe(200);
  expect(res.ok).toBe(true);
});

// Check for specific error shapes
test('validation error response', async () => {
  const res = await request(app)
    .post('/api/users')
    .send({ name: '' })
    .expect(400);

  expect(res.body).toMatchObject({
    error: 'Validation failed',
    details: expect.arrayContaining([
      expect.objectContaining({ field: 'name', message: expect.any(String) }),
      expect.objectContaining({ field: 'email' }),
    ]),
  });
});

// Redirect assertion
test('redirect to login', async () => {
  await request(app)
    .get('/dashboard')
    .expect(302)
    .expect('Location', '/login');
});

Fix 5: Database Setup and Teardown

import request from 'supertest';
import app from '../src/app';
import { db } from '../src/db';
import { users } from '../src/schema';

beforeEach(async () => {
  // Clean database before each test
  await db.delete(users);

  // Seed test data
  await db.insert(users).values([
    { id: '1', name: 'Alice', email: '[email protected]', role: 'admin' },
    { id: '2', name: 'Bob', email: '[email protected]', role: 'user' },
  ]);
});

afterAll(async () => {
  // Close database connection
  await db.$client.end();
});

test('GET /api/users returns seeded data', async () => {
  const res = await request(app).get('/api/users').expect(200);
  expect(res.body).toHaveLength(2);
  expect(res.body[0].name).toBe('Alice');
});

test('DELETE /api/users/:id removes user', async () => {
  await request(app).delete('/api/users/1').expect(204);

  const res = await request(app).get('/api/users').expect(200);
  expect(res.body).toHaveLength(1);
  expect(res.body[0].name).toBe('Bob');
});

Fix 6: Fastify and Hono Testing

// Fastify — use inject instead of Supertest
import { build } from '../src/app';

test('GET /api/users', async () => {
  const app = await build();

  const res = await app.inject({
    method: 'GET',
    url: '/api/users',
    headers: { authorization: 'Bearer token' },
  });

  expect(res.statusCode).toBe(200);
  expect(JSON.parse(res.payload)).toHaveLength(2);
});

// Hono — use app.request()
import app from '../src/app';

test('GET /api/users', async () => {
  const res = await app.request('/api/users');
  expect(res.status).toBe(200);

  const body = await res.json();
  expect(body).toHaveLength(2);
});

// Hono with body
test('POST /api/users', async () => {
  const res = await app.request('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: 'Alice' }),
  });
  expect(res.status).toBe(201);
});

Still Not Working?

Tests hang and time out — the app calls app.listen() in the module you’re importing. Separate the Express app creation from listen(). Export app from one file and call listen() in a different entry point that tests don’t import.

EADDRINUSE error — a previous test left a server running. Use Supertest’s request(app) pattern (passes the app, not a URL). This creates an ephemeral server per request that auto-closes. Don’t use request('http://localhost:3000').

Response body is empty — your route handler might not be sending a response. Check for missing res.json() or res.send(). Also check that async errors are caught — an unhandled promise rejection in Express may cause the request to hang instead of returning 500.

Tests pass locally, fail in CI — usually a database issue. CI doesn’t have your local database. Use a test database (Docker PostgreSQL in CI) or mock the database layer. Also check for time-dependent tests that rely on specific dates.

Tests pass individually but fail when the suite runs in parallel — multiple Supertest runs share the same database. Test A deletes a row that Test B needs. Either serialize the suite with Jest’s --runInBand (slower but deterministic) or give each test file its own database schema, created and dropped in beforeAll/afterAll.

Open handle warnings on test exit — your database client or background workers didn’t close. Jest detects this with --detectOpenHandles. Add explicit cleanup: afterAll(async () => { await db.$client.end(); await redis.quit(); }). Each unclosed handle leaks one process slot.

Tests pass but production returns a different status code — the test app is missing production middleware. If production uses helmet, cors, rate-limit, or a custom error handler, the test must apply them too. The clean solution is to wrap app construction in a single factory: createApp(options), used identically by server.ts and tests.

For related testing issues, see Fix: Vitest Setup Not Working, Fix: MSW Not Working, Fix: Playwright Not Working, and Fix: Fastify Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles