Unit tests are valuable, but they operate in a bubble. They mock databases, stub HTTP clients, and fake auth middleware — which means they can pass with flying colours while your actual API silently returns a 500 on every authenticated request. That gap is exactly what integration tests are designed to close.

This guide walks you through building a realistic integration test suite for a Node.js REST API using Vitest and Supertest. By the end, you will have a setup that spins up a real server, seeds test data, makes actual HTTP calls, and tears everything down cleanly — without slowing your CI pipeline to a crawl.

Why Vitest and Supertest?

Vitest is a Vite-native test runner that shares your project's existing config and supports ES modules out of the box. It is faster than Jest for most modern TypeScript projects and drops in with minimal configuration.

Supertest wraps Node's http.Server and lets you fire real HTTP requests against it without binding to a port. That means no flaky port conflicts and no need to manage a live server process in CI.

Together they give you the speed of an in-process test runner and the realism of actual HTTP semantics.

Project Structure

Assume a typical Express API with this layout:

src/
  app.ts          # Express app (no app.listen here)
  server.ts       # Entry point that calls app.listen
  routes/
  middleware/
tests/
  integration/
    helpers/
      seed.ts
      teardown.ts
    auth.test.ts
    users.test.ts

The critical pattern: keep app.ts and server.ts separate. Supertest imports app directly and manages the lifecycle itself. If listen is called at import time, you lose that control.

Setting Up Vitest for Integration Tests

Install dependencies:

npm install -D vitest supertest @types/supertest

Add a dedicated Vitest config for integration tests so they run separately from unit tests:

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

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

Add a script to package.json:

"test:integration": "vitest run --config vitest.integration.ts"

Running integration tests in a single thread avoids race conditions when multiple test files share a database.

Database Seeding

Hard-coded fixture data causes brittle tests. Instead, write a seed.ts helper that inserts known records before each test file runs and returns their IDs for use in assertions.

// tests/integration/helpers/seed.ts
import { db } from '../../../src/db';

export async function seedUsers() {
  await db('users').del();
  const [id] = await db('users').insert({
    name: 'Ada Lovelace',
    email: 'ada@example.com',
    role: 'admin',
  });
  return { userId: id };
}

Call it inside beforeAll in each test file. Keep seeds minimal — insert only what the test actually needs. Bloated seeds are one of the fastest ways to make integration suites slow and hard to debug.

Handling Auth Headers

Most real APIs require authentication. Do not skip it in tests; that only hides contract bugs in your auth middleware.

Create a small helper that generates a valid token for a test user:

// tests/integration/helpers/auth.ts
import jwt from 'jsonwebtoken';

export function makeTestToken(payload: object = {}) {
  return jwt.sign(
    { sub: 'test-user-id', role: 'admin', ...payload },
    process.env.JWT_SECRET!,
    { expiresIn: '1h' }
  );
}

Then use it in your requests:

const token = makeTestToken();
const res = await request(app)
  .get('/api/users/1')
  .set('Authorization', `Bearer ${token}`);

This tests the full auth pipeline — token parsing, role checks, and all — not a mocked version of it.

Writing a Test File

Here is a complete example for a GET /api/users/:id endpoint:

// tests/integration/users.test.ts
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import app from '../../src/app';
import { seedUsers } from './helpers/seed';
import { makeTestToken } from './helpers/auth';
import { db } from '../../src/db';

let userId: number;
let token: string;

beforeAll(async () => {
  ({ userId } = await seedUsers());
  token = makeTestToken({ role: 'admin' });
});

afterAll(async () => {
  await db('users').del();
  await db.destroy();
});

describe('GET /api/users/:id', () => {
  it('returns the user when authenticated', async () => {
    const res = await request(app)
      .get(`/api/users/${userId}`)
      .set('Authorization', `Bearer ${token}`)
      .expect(200);

    expect(res.body.data.email).toBe('ada@example.com');
  });

  it('returns 401 when no token is provided', async () => {
    await request(app).get(`/api/users/${userId}`).expect(401);
  });

  it('returns 404 for a non-existent user', async () => {
    await request(app)
      .get('/api/users/999999')
      .set('Authorization', `Bearer ${token}`)
      .expect(404);
  });
});

Notice what is being tested here beyond happy-path logic: the shape of the response body, HTTP status codes for error states, and authentication enforcement. These are exactly the contracts that unit tests cannot verify.

Teardown Patterns That Keep CI Fast

Slow teardown is the main reason teams abandon integration test suites. A few rules help:

  • Delete only what you inserted. Use db('table').del() scoped to known IDs rather than truncating entire tables.
  • Destroy the DB connection in afterAll, not between tests. Re-establishing connections is expensive.
  • Use a separate test database. Set DATABASE_URL to a test-only instance in CI via environment variables. Never run integration tests against a shared staging database.
  • Run integration tests after unit tests in CI. Gate the pipeline so a failing unit test aborts before the slower integration phase runs.

A typical GitHub Actions step looks like this:

- name: Run integration tests
  env:
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
    JWT_SECRET: ${{ secrets.JWT_SECRET }}
  run: npm run test:integration

Common Mistakes to Avoid

  • Testing implementation details — assert on HTTP status codes and response bodies, not on internal function calls.
  • Shared mutable state between tests — always seed fresh data in beforeAll and clean up in afterAll.
  • Ignoring error response shapes — your error payloads are part of your API contract. Assert on them explicitly.
  • Skipping edge cases — missing required fields, invalid UUIDs, and expired tokens are the scenarios that matter most in production.

Why This Matters for Your Project

Whether you are building a SaaS product, a mobile app backend, or a client-facing API, your integration test suite is the last automated checkpoint before code reaches users. A well-structured setup with Vitest and Supertest adds less than a minute to most CI pipelines while catching the class of bugs — broken auth flows, malformed responses, missing status codes — that slip through even thorough unit test coverage. Invest in this layer early, and it pays compounding dividends every time a refactor touches your route handlers.