How to Write Integration Tests for a Node.js REST API

Your unit tests are green. Every function passes. You ship, and within an hour a user reports that POST /orders returns a 500 — because a required foreign key constraint was never enforced in the mock you used for testing.

This is the gap integration tests exist to close. They exercise your API the way a real client would: sending HTTP requests, touching a real database, and asserting on the actual response. No mocks, no illusions.

Why Unit Tests Are Not Enough

Unit tests are fast and surgical. They verify that an individual function does what it claims. But a REST API is a chain of concerns — routing, middleware, validation, business logic, database queries, and serialization. A unit test can confirm that your createOrder function returns the right object, yet still miss:

  • A misconfigured Express route that never calls the handler
  • A Sequelize or Prisma query that fails against a real schema
  • A JWT middleware that rejects valid tokens due to an environment variable mismatch
  • Response serialisation that strips a field the frontend depends on

Integration tests run the full chain. That is their entire value proposition.

The Stack

This guide uses:

  • Node.js with Express for the API
  • Jest as the test runner
  • Supertest to fire HTTP requests against a live server instance
  • PostgreSQL (or any SQL database) with a dedicated test database

Install the dependencies:

npm install --save-dev jest supertest

If you are using TypeScript, add ts-jest and the relevant type definitions as well.

Project Structure Assumptions

src/
  app.js          # Express app (no app.listen here)
  server.js       # Entry point — calls app.listen
  routes/
  controllers/
  models/
tests/
  integration/
    orders.test.js

Separating app.js from server.js is critical. Supertest needs to import the Express app object directly — not a running server bound to a port. If app.listen lives inside app.js, Supertest will fight for the port and your tests will hang or fail unpredictably.

Setting Up the Test Database

Never run integration tests against your development or production database. Use a dedicated test database — identical schema, disposable data.

Set a NODE_ENV=test environment variable and configure your database client to switch connection strings accordingly:

// config/database.js
const configs = {
  development: { url: process.env.DATABASE_URL },
  test: { url: process.env.TEST_DATABASE_URL },
};
module.exports = configs[process.env.NODE_ENV || "development"];

In your Jest config (jest.config.js), set:

module.exports = {
  testEnvironment: "node",
  globalSetup: "./tests/setup.js",
  globalTeardown: "./tests/teardown.js",
};

globalSetup runs once before the entire suite — use it to run migrations and seed baseline data. globalTeardown drops or truncates tables after the suite finishes.

Writing Your First Integration Test

Here is a test for a POST /api/orders endpoint that requires authentication:

// tests/integration/orders.test.js
const request = require("supertest");
const app = require("../../src/app");
const db = require("../../src/models");

beforeEach(async () => {
  await db.Order.destroy({ where: {}, truncate: true });
});

afterAll(async () => {
  await db.sequelize.close();
});

describe("POST /api/orders", () => {
  it("returns 201 and the created order when payload is valid", async () => {
    const res = await request(app)
      .post("/api/orders")
      .set("Authorization", `Bearer ${global.testToken}`)
      .send({ productId: 1, quantity: 2 });

    expect(res.statusCode).toBe(201);
    expect(res.body).toHaveProperty("id");
    expect(res.body.quantity).toBe(2);
  });

  it("returns 400 when quantity is missing", async () => {
    const res = await request(app)
      .post("/api/orders")
      .set("Authorization", `Bearer ${global.testToken}`)
      .send({ productId: 1 });

    expect(res.statusCode).toBe(400);
    expect(res.body.error).toMatch(/quantity/i);
  });

  it("returns 401 when no token is provided", async () => {
    const res = await request(app)
      .post("/api/orders")
      .send({ productId: 1, quantity: 2 });

    expect(res.statusCode).toBe(401);
  });
});

Notice what this single test file verifies that unit tests would miss entirely: the authentication middleware is wired correctly, the validation layer returns a meaningful error message, and the database actually persists the record (you can add a db.Order.findByPk assertion to confirm).

Patterns That Keep Integration Tests Maintainable

1. Isolate state between tests

Use beforeEach to truncate or reset the tables your test touches. Shared state between tests is the fastest path to flaky, order-dependent test suites.

2. Generate test tokens programmatically

Create a generateTestToken utility in your setup file that mints a valid JWT using your actual signing secret. This tests the real middleware, not a bypassed version.

3. Test the unhappy paths deliberately

Most production bugs live in the unhappy paths — invalid input, expired tokens, missing records, constraint violations. For every endpoint, ask: what happens if the database is in an unexpected state? Test that too.

4. Keep integration tests separate from unit tests

Give them their own directory and a separate npm script (npm run test:integration). Integration tests are slower — you do not want them blocking the fast feedback loop during development.

5. Run them in CI against a real database container

Use a GitHub Actions service container or a Docker Compose file to spin up PostgreSQL during your CI pipeline. An integration suite that only runs locally is not an integration suite — it is a liability.

What This Catches in Practice

The three bugs integration tests reliably surface that unit tests miss:

  • Schema drift — A migration that was never applied to the test environment breaks a query that worked fine against mocks.
  • Middleware ordering bugs — Validation middleware that runs after the database call instead of before, caught only when both are exercised together.
  • Serialization gaps — A model that exposes a password hash in the JSON response because the toJSON override was forgotten.

Why This Matters for Your Project

If you are building or scaling a SaaS product, a mobile backend, or any API that powers real users, integration tests are the safety net that makes confident deployment possible. Unit tests tell you your code is logically correct. Integration tests tell you your system actually works. The two are not interchangeable — you need both. Starting with Supertest and a dedicated test database is the lowest-friction entry point, and the confidence it returns compounds with every feature you ship.