Skip to content
Express.js ex testing 5 min read

Integration Testing with Supertest

Integration tests are where Express applications earn their confidence: they drive a real request through your actual middleware chain — body parsing, validation, authentication, the route handler, and the error handler — and assert on the response that comes back. Supertest makes this fast by talking to your app object directly, in-process, without ever binding a TCP port. This page shows how to export the app for testing, send requests with Supertest, assert on status, body, and headers, and cover the tricky paths: authentication and errors.

Why Supertest

Supertest wraps your Express app (or any Node HTTP handler) and gives you a fluent, chainable API for building requests and asserting on responses. Because it hands the request straight to the app instead of opening a socket, tests run in milliseconds and in parallel without port conflicts. It builds on the superagent HTTP client, so the request-building API will feel familiar.

The single prerequisite is structural: your app must be importable without starting a server. Define and export the app in one module, and call app.listen() somewhere else.

// app.js — define and export; never listen here
const express = require('express');
const ordersRouter = require('./routes/orders');

const app = express();
app.use(express.json());
app.use('/orders', ordersRouter);

// Centralized error handler (must have 4 args)
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

module.exports = app;
npm install --save-dev jest supertest

Your first integration test

Import the app, wrap it with request(), and chain HTTP verbs and assertions. The returned object is thenable, so await resolves to a response with status, body, and headers.

// orders.test.js
const request = require('supertest');
const app = require('./app');

test('GET /orders returns a list', async () => {
  const res = await request(app).get('/orders');

  expect(res.status).toBe(200);
  expect(Array.isArray(res.body)).toBe(true);
});

Output:

PASS  ./orders.test.js
  ✓ GET /orders returns a list (24 ms)

Tests: 1 passed, 1 total

Asserting status, body, and headers

You can assert imperatively (read the response, then use your runner’s matchers) or with Supertest’s built-in .expect() assertions, which fail the request promise when they don’t match. Both styles are valid; imperative assertions read well with Jest, while .expect() keeps everything in the chain.

test('POST /orders creates an order', async () => {
  const res = await request(app)
    .post('/orders')
    .send({ sku: 'A1', qty: 2 })
    .set('Accept', 'application/json');

  expect(res.status).toBe(201);
  expect(res.headers['content-type']).toMatch(/application\/json/);
  expect(res.body).toMatchObject({ sku: 'A1', qty: 2 });
  expect(res.body).toHaveProperty('id');
});

test('chained .expect() form', async () => {
  await request(app)
    .post('/orders')
    .send({ sku: 'B2', qty: 1 })
    .expect(201)
    .expect('Content-Type', /json/)
    .expect((res) => {
      if (!res.body.id) throw new Error('missing id');
    });
});

The most common request builders are summarized below.

MethodPurpose
.get/.post/.put/.patch/.delete(path)Choose the HTTP verb and path
.send(body)JSON or form body (sets Content-Type automatically)
.set(name, value)Set a request header (e.g. Authorization)
.query({ ... })Append a query string
.field() / .attach()Build multipart/form-data for file uploads
.expect(status | header | fn)Inline assertion that rejects on mismatch

Tip: Supertest sets Content-Type: application/json for you when you pass an object to .send(). Pass a string and it is sent verbatim, which is handy for testing how your app handles malformed JSON.

Testing authenticated routes

Protected routes expect a credential — usually a bearer token or a session cookie. Attach it with .set(). A clean pattern is a small helper that mints a valid token so each test reads clearly.

const jwt = require('jsonwebtoken');

function authHeader(user = { id: 'u1', role: 'admin' }) {
  const token = jwt.sign(user, process.env.JWT_SECRET || 'test-secret');
  return `Bearer ${token}`;
}

test('rejects unauthenticated requests', async () => {
  const res = await request(app).get('/orders/secret');
  expect(res.status).toBe(401);
});

test('allows authenticated requests', async () => {
  const res = await request(app)
    .get('/orders/secret')
    .set('Authorization', authHeader());

  expect(res.status).toBe(200);
});

When auth relies on cookies, reuse them across requests with an agent. request.agent(app) persists cookies between calls, so a login response’s Set-Cookie is automatically replayed on subsequent requests.

test('logs in then accesses a session route', async () => {
  const agent = request.agent(app);

  await agent
    .post('/login')
    .send({ email: '[email protected]', password: 'pw' })
    .expect(200);

  await agent.get('/profile').expect(200); // cookie sent automatically
});

Testing error and validation paths

Honest integration tests cover the unhappy paths too: bad input, missing resources, and thrown errors. These exercise your validation middleware and the centralized error handler. Assert on both the status code and the error body shape so the contract is pinned down.

test('returns 400 on invalid body', async () => {
  const res = await request(app)
    .post('/orders')
    .send({ qty: -5 }); // missing sku, bad qty

  expect(res.status).toBe(400);
  expect(res.body).toHaveProperty('error');
});

test('returns 404 for a missing order', async () => {
  const res = await request(app).get('/orders/does-not-exist');
  expect(res.status).toBe(404);
});

Output:

PASS  ./orders.test.js
  ✓ returns 400 on invalid body (11 ms)
  ✓ returns 404 for a missing order (8 ms)

Warning: In Express 5, async route handlers that reject are forwarded to the error handler automatically. In Express 4 you must try/catch and call next(err) yourself — otherwise a rejected promise leaks and your error-path test will hang instead of returning a clean 500.

Best Practices

  • Export app from its own module and keep app.listen() in a separate server.js so Supertest never binds a port.
  • Prefer driving the app object over a running URL — it is faster, parallel-safe, and needs no network.
  • Assert on status, headers, and body shape together so the full response contract is covered.
  • Test the unhappy paths (401, 400, 404, 500) explicitly; they exercise your middleware and error handler.
  • Use request.agent(app) to carry session cookies across a multi-step flow like login then fetch.
  • Set NODE_ENV=test and point at a disposable database, resetting shared state between tests for order-independence.
  • Keep secrets like JWT_SECRET in a test config so token-minting helpers stay deterministic.
Last updated June 14, 2026
Was this helpful?