Most Node.js projects have unit tests. Far fewer have integration tests that actually exercise the full HTTP stack — router, middleware, controller, and database — in a single request-response cycle. That gap is exactly where silent contract breakages hide until they hit production.

This guide walks through building a practical, database-backed integration test suite for a REST API using Vitest and Supertest. The patterns here are battle-tested for SaaS backends where speed and correctness both matter.


Why Unit Tests Are Not Enough

Unit tests verify isolated logic. They mock the database, stub external calls, and test one function at a time. That is valuable — but it also means your tests never confirm that:

  • Your Express router wires up to the right controller
  • Your middleware chain (auth, validation, error handling) runs in the correct order
  • Your ORM query actually returns the shape your serializer expects
  • A breaking schema migration silently corrupts a response payload

Integration tests close that gap. They spin up your actual application, hit real endpoints over HTTP, and assert on real responses — with a real (test) database behind them.


The Stack: Vitest + Supertest

Vitest is a fast, ESM-native test runner with a Jest-compatible API. It uses Vite's transform pipeline, which makes it significantly faster than Jest for TypeScript projects. It also supports beforeAll/afterAll lifecycle hooks cleanly, which integration tests depend on heavily.

Supertest wraps Node's http.Server and lets you make HTTP requests against your Express (or Fastify, Koa, etc.) app without binding to a real port. Requests are in-process, so tests stay fast.

Together, they give you a test suite that runs in seconds, not minutes.


Project Setup

Install the required packages:

npm install --save-dev vitest supertest @types/supertest

In your vitest.config.ts, configure a separate test environment for integration tests to avoid conflicts with your unit test setup:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.integration.test.ts'],
    setupFiles: ['src/tests/setup.ts'],
    poolOptions: {
      threads: {
        singleThread: true, // prevents DB race conditions across test files
      },
    },
  },
});

Setting singleThread: true is critical when tests share a database. Parallel threads writing and reading the same tables produce flaky, unpredictable results.


Structuring the Test Setup File

Your setup.ts file handles the database connection lifecycle. For a Prisma-based project, it looks like this:

// src/tests/setup.ts
import { prisma } from '../lib/prisma';

beforeAll(async () => {
  await prisma.$connect();
});

afterAll(async () => {
  await prisma.$disconnect();
});

Keep this file lean. Database seeding belongs inside individual test files, not in global setup — it keeps tests self-contained and easier to debug.


Writing Your First Integration Test

Here is a complete example testing a GET /users/:id endpoint:

// src/users/users.integration.test.ts
import request from 'supertest';
import { app } from '../app';
import { prisma } from '../lib/prisma';

describe('GET /users/:id', () => {
  let userId: string;

  beforeEach(async () => {
    const user = await prisma.user.create({
      data: {
        name: 'Ama Owusu',
        email: 'ama@example.com',
      },
    });
    userId = user.id;
  });

  afterEach(async () => {
    await prisma.user.deleteMany();
  });

  it('returns the user when found', async () => {
    const res = await request(app).get(`/users/${userId}`);

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({
      id: userId,
      name: 'Ama Owusu',
      email: 'ama@example.com',
    });
  });

  it('returns 404 when the user does not exist', async () => {
    const res = await request(app).get('/users/nonexistent-id');

    expect(res.status).toBe(404);
    expect(res.body.message).toBe('User not found');
  });
});

A few deliberate choices in this pattern worth noting:

  • beforeEach seeds, afterEach tears down. This ensures each test starts from a clean, known state. Tests that depend on leftover data from previous tests are a ticking time bomb.
  • toMatchObject instead of toEqual. Your API response may include extra fields like createdAt. toMatchObject checks for the subset you care about without being brittle.
  • No mocking. The whole point of integration tests is to avoid mocks. If your test mocks the database, it is a unit test wearing a costume.

Patterns for Authenticated Endpoints

Most real APIs have protected routes. Rather than mocking your auth middleware, generate a real token as part of your test setup:

beforeAll(async () => {
  const res = await request(app).post('/auth/login').send({
    email: 'test@example.com',
    password: 'test-password',
  });
  authToken = res.body.token;
});

Then pass it as a header:

const res = await request(app)
  .get('/protected-resource')
  .set('Authorization', `Bearer ${authToken}`);

This tests your auth flow end-to-end rather than assuming the middleware works.


Keeping Tests Fast: Practical Tips

Integration tests have a reputation for being slow. They do not have to be.

  • Use a dedicated test database. Set DATABASE_URL to a separate test DB in your .env.test file and point Vitest to it with dotenv.
  • Truncate, do not drop. deleteMany() is faster than running migrations between tests.
  • Seed minimally. Only create the exact records each test needs. Avoid giant seed scripts that create hundreds of rows "just in case."
  • Run integration tests separately in CI. Keep them out of your pre-commit hook. Run unit tests on every commit and integration tests on every pull request.

Why This Matters for Your Project

If you are building a SaaS product or any backend that other services depend on, integration tests are not optional — they are your deployment safety net. Every time you refactor a controller, add a middleware, or run a schema migration, your integration suite tells you immediately whether the HTTP contract your clients rely on is still intact. Vitest's speed makes it realistic to run these tests on every PR without slowing down your pipeline. The upfront investment in seeding and teardown patterns pays back in confidence at every release.