Skip to content
Express.js ex testing 4 min read

Testing Express Apps

A web API is only as trustworthy as the tests that guard it. Express applications are made of small, composable pieces — route handlers, middleware, services, and the HTTP layer that stitches them together — and each layer benefits from a different kind of test. The challenge is knowing what to test where, so you get fast feedback without an unmaintainable mess of slow, brittle tests. This page frames the testing pyramid for Express and shows the single structural change that makes everything below it possible: separating your app from server startup.

The testing pyramid

The testing pyramid is a guideline for how to distribute tests across layers. Cheap, fast tests form a wide base; slow, expensive tests sit at a narrow top. You want many of the former and few of the latter.

LayerWhat it coversSpeedQuantity
UnitPure functions, services, a single middleware in isolationMillisecondsMany
IntegrationRoutes through the real Express stack (middleware, validation, controller)FastSome
End-to-end (E2E)The running server plus real network, DB, and dependenciesSlowFew

Unit tests verify logic in isolation — a price calculator, a token parser, a validation rule. Integration tests exercise a route through Express’s actual middleware chain but stop short of the network and external systems, usually by driving the app object directly with a tool like Supertest. E2E tests boot the whole system and hit it over real HTTP, catching wiring problems that lower layers cannot.

Tip: The pyramid is about cost, not snobbery. A single integration test that runs a request through routing, validation, and your controller often delivers more confidence per line than a dozen narrow unit tests.

What to test in an Express app

Map each piece of an Express app to the layer where it is cheapest to test honestly.

  • Business logic / services — Unit tests. Keep this code free of req/res so it can be called directly.
  • Pure middleware (auth checks, transformers) — Unit tests with fake req, res, and next.
  • Routes end to end through Express — Integration tests against app using Supertest: status codes, headers, JSON bodies, error responses.
  • Critical user journeys — A handful of E2E tests against a deployed or fully booted instance.

A common anti-pattern is putting database queries and HTTP-shaped logic directly inside route handlers, which forces every test to be a slow integration test. Pushing logic into plain functions lets the bulk of your suite stay at the fast unit layer.

// Thin handler: delegates to a unit-testable service
const { createOrder } = require('../services/orders');

router.post('/orders', async (req, res, next) => {
  try {
    const order = await createOrder(req.body, req.user.id);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

Choosing a test runner

Express is unopinionated about tooling. Any modern runner works; pick one and standardize.

RunnerNotes
JestBatteries-included: assertions, mocking, coverage. Most popular choice.
VitestJest-compatible API, very fast, first-class ESM and TypeScript.
node:testBuilt into Node 18+, zero dependencies, run with node --test.
Mocha + ChaiFlexible, classic, requires assembling assertion and mock libraries.

For HTTP-level integration tests, pair the runner with Supertest, which sends requests directly to your app without binding a port.

npm install --save-dev jest supertest

Separate the app from server startup

This is the most important refactor for testability. If your file both defines the app and calls app.listen(), importing it during a test starts a real server and binds a port — every test file would race for the port and leak open handles. Split the two: build and export the app in one module, and start the server in another.

// app.js — define and export, do NOT listen here
const express = require('express');
const app = express();

app.use(express.json());
app.use('/orders', require('./routes/orders'));

app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

module.exports = app;
// server.js — the only place that listens
const app = require('./app');
const port = process.env.PORT || 3000;

app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Now tests import app directly and hand it to Supertest — no port, no open sockets, fully parallelizable.

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

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

  expect(res.status).toBe(201);
  expect(res.body).toHaveProperty('id');
});

Output:

PASS  ./orders.test.js
  ✓ POST /orders creates an order (28 ms)

Tests: 1 passed, 1 total

Warning: Never call app.listen() inside an imported module that tests load. Doing so binds a port on import and leaves the runner hanging with open handles, often forcing a --forceExit.

Best Practices

  • Keep handlers thin: move business logic into plain functions so most tests stay at the fast unit layer.
  • Export app from one module and listen from another so tests never bind a real port.
  • Follow the pyramid — many unit tests, fewer integration tests, a small set of E2E checks.
  • Drive integration tests with Supertest against the app object rather than a live URL.
  • Standardize on one runner (Jest, Vitest, or node:test) and wire coverage into CI.
  • Set NODE_ENV=test to load a dedicated config and a disposable test database.
  • Make tests independent: reset shared state between cases so they can run in any order and in parallel.
Last updated June 14, 2026
Was this helpful?