Skip to content

Testing Express Applications

Automated tests catch regressions before they reach production and give you the confidence to refactor. This lesson maps out the different levels of testing for an Express app before diving into each in later lessons.

The Testing Pyramid for Express Apps

Unit tests check a single function in isolation, usually a service or utility function, with no Express server involved at all. Integration tests check that several pieces work together correctly, often a full route handled through a real (in-memory) Express app. End-to-end tests exercise the entire deployed system, including a real database and network calls.

A healthy Express test suite usually has many fast unit tests, a smaller number of integration tests covering key routes, and few, if any, full end-to-end tests, which are the slowest and most brittle.

// unit test (no Express involved)
test('calculateTotal adds tax correctly', () => {
  expect(calculateTotal(100, 0.1)).toBe(110);
});

// integration test (real Express app, via supertest)
test('GET /users returns 200', async () => {
  const res = await request(app).get('/users');
  expect(res.status).toBe(200);
});

The unit test needs nothing but the function itself; the integration test spins up the actual Express app and sends a real HTTP-like request to it.

Common Testing Tools for Express

Jest / Vitest / Mocha  -> test runner and assertion library
supertest               -> HTTP assertions against an Express app
mongodb-memory-server   -> in-memory MongoDB for isolated integration tests
nock                    -> mocking outbound HTTP calls in tests
  • Jest is the most common all-in-one test runner and assertion library for Node/Express projects.
  • supertest lets you send requests directly to your app object without binding a real network port.
  • Use an in-memory or dedicated test database so tests never touch production data.
  • Mock external services (payment providers, third-party APIs) rather than calling them in tests.

Testing Levels Cheatsheet

A quick summary of the testing levels covered in this section.

Level Scope Speed Tool
Unit Single function Very fast Jest / Vitest
Integration Route + middleware + (mock) DB Fast Jest + supertest
End-to-end Full deployed stack Slow Playwright, Cypress, etc.

Why Separating app.js and server.js Matters for Tests

Recall the pattern from the Setup lesson: exporting app from app.js without calling listen() there means test files can require('../app') and pass it straight into supertest, with no real network port ever opened.

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

test('GET /health returns ok', async () => {
  const res = await request(app).get('/health');
  expect(res.body).toEqual({ status: 'ok' });
});

Test Environment Configuration

Tests should run against their own isolated configuration, a separate test database, mocked external services, and NODE_ENV=test, so running the suite never risks touching real data.

// .env.test
NODE_ENV=test
DATABASE_URL=mongodb://localhost:27017/myapp_test

Common Mistakes

  • Running tests against a real production or shared development database.
  • Writing only end-to-end tests, resulting in a slow, flaky, hard-to-maintain suite.
  • Not separating app.js from server.js, making the app hard to test without opening a real port.
  • Skipping tests entirely for critical business logic like pricing or authentication.

Key Takeaways

  • Unit, integration, and end-to-end tests each serve a different purpose at a different speed and scope.
  • A healthy suite favors many fast unit and integration tests over few slow end-to-end ones.
  • Exporting app without calling listen() makes integration testing with supertest straightforward.
  • Tests should always run against isolated, disposable configuration, never real production data.

Pro Tip

Add a test script to package.json and run it in CI on every pull request, tests that only run manually on a developer's machine eventually stop running at all.