Integration Testing a Full-Stack App

· 6 min read · Updated March 21, 2026 · advanced
javascript node testing api

Integration tests sit between unit tests and end-to-end tests. You spin up your real HTTP server, connect to a real database, and exercise complete request/response cycles. The payoff: you catch bugs that unit tests miss, and you run them far faster than E2E suites. This tutorial covers the tools, patterns, and gotchas for testing a Node.js full-stack app in JavaScript.

Project Setup

For a modern Vite/ESM project, Vitest is the clear choice. It shares the same config surface as Vite, runs in Node, and has first-class TypeScript support.

Install the test dependencies:

npm install -D vitest @vitest/ui
npm install supertest

Update vite.config.js to enable test mode:

// vite.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,        // describe, it, expect available without imports
    environment: 'node', // not jsdom — we test the API layer
    setupFiles: ['./tests/setup.js'],
  },
});

Add a test script to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Writing Your First API Test

Create a test file at tests/integration/users.test.js. Use Supertest to make HTTP requests against your Express app without binding to a real port.

// tests/integration/users.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';  // your Express app

describe('GET /api/users', () => {
  it('returns a list of users', async () => {
    const res = await request(app).get('/api/users');

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
    // Output: []
  });
});

The key detail: request(app) passes your Express app instance directly to Supertest. Supertest calls app.listen() internally only for the test run — no real network socket is bound in your source code.

Database Transaction Rollback Pattern

The hardest part of integration testing is keeping test suites isolated from each other. If test A inserts a row and test B expects an empty table, your suite will flake randomly depending on execution order.

The solution is a transaction wrapper. Begin a transaction before each test, then roll it back after the test completes. Nothing is ever committed to the database.

// tests/setup.js
import { db } from '../src/db.js';  // your database client (e.g., postgres, mysql)

export async function setupDatabase() {
  await db.query('BEGIN');
}

export async function teardownDatabase() {
  await db.query('ROLLBACK');
}

Wire this into Vitest with beforeEach and afterEach:

// tests/integration/todos.test.js
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupDatabase, teardownDatabase } from '../setup.js';
import request from 'supertest';
import { app } from '../../src/app.js';

beforeEach(setupDatabase);
afterEach(teardownDatabase);

describe('POST /api/todos', () => {
  it('creates a new todo', async () => {
    const res = await request(app)
      .post('/api/todos')
      .send({ title: 'Write tests', completed: false });

    expect(res.status).toBe(201);
    expect(res.body.title).toBe('Write tests');
    expect(res.body.id).toBeDefined();
    // Output: { id: 1, title: 'Write tests', completed: false }
  });

  it('returns 400 when title is missing', async () => {
    const res = await request(app)
      .post('/api/todos')
      .send({ completed: false });

    expect(res.status).toBe(400);
    // Output: { error: 'title is required' }
  });
});

Each beforeEach opens a fresh transaction. When the test finishes, afterEach rolls back every change — inserts, updates, deletes — as if the test never happened.

Testing Authenticated Routes

Auth flows require a login step that produces a session cookie or JWT. Extract the auth token from the login response, then attach it to subsequent requests.

// tests/integration/orders.test.js
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupDatabase, teardownDatabase } from '../setup.js';
import request from 'supertest';
import { app } from '../../src/app.js';

let authCookie;

beforeEach(setupDatabase);
afterEach(teardownDatabase);

describe('GET /api/orders', () => {
  it('returns 401 when not authenticated', async () => {
    const res = await request(app).get('/api/orders');
    expect(res.status).toBe(401);
  });

  it('returns orders for authenticated user', async () => {
    // 1. Create and log in a user
    await request(app)
      .post('/api/users')
      .send({ name: 'Carol', email: 'carol@example.com', password: 'secret123' });

    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({ email: 'carol@example.com', password: 'secret123' });

    // 2. Extract the session cookie
    authCookie = loginRes.headers['set-cookie'];
    // Output: [ 'session=abc123; HttpOnly; Path=/; SameSite=Strict' ]

    // 3. Use the cookie on authenticated requests
    const res = await request(app)
      .get('/api/orders')
      .set('Cookie', authCookie);

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });
});

For JWT-based auth, extract the token from the response body instead:

const loginRes = await request(app)
  .post('/api/auth/login')
  .send({ email: 'carol@example.com', password: 'secret123' });

const { token } = loginRes.body;
// Output: { token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }

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

Common Gotchas

Not awaiting async calls. Every request() call returns a Promise. Forgetting await causes the assertion to run before the response arrives, leading to confusing failures.

// Wrong — test will fail or behave unpredictably
it('breaks', () => {
  const res = request(app).get('/api/users');
  expect(res.status).toBe(200);  // res is a PendingRequest, not a response
});

// Correct
it('works', async () => {
  const res = await request(app).get('/api/users');
  expect(res.status).toBe(200);
});

Database state leaking between tests. If you skip the transaction rollback pattern, a test that inserts a row will leave that row in the database for the next test. Over time your test suite accumulates garbage data and tests fail for reasons unrelated to the code under test. Always use BEGIN/ROLLBACK.

Testing against a shared development database. Running integration tests against your local postgres://localhost/mydb means tests compete for the same data. Use a separate test database, or wrap everything in transactions as shown above. The transaction pattern is the simpler fix and works regardless of your DB provider.

Not closing the server between test files. When you have multiple test files, Vitest may run them in parallel workers. If your app binds a port, two workers can conflict. Pass the app instance to Supertest without manually calling app.listen() anywhere in your source — Supertest handles this.

Testing Fastify

The Supertest approach works for Express, but Fastify has its own testing plugin. Use fastify.inject() instead of Supertest:

// tests/integration/fastify-users.test.js
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupDatabase, teardownDatabase } from '../setup.js';
import buildApp from '../../src/app.js';

let app;

beforeEach(async () => {
  await setupDatabase();
  app = await buildApp();
});

afterEach(async () => {
  await teardownDatabase();
  await app.close();
});

describe('GET /api/users', () => {
  it('returns users', async () => {
    const res = await app.inject({
      method: 'GET',
      url: '/api/users',
    });

    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.body)).toBeInstanceOf(Array);
    // Output: []
  });
});

Fastify’s inject() is lightweight and doesn’t touch the network stack at all, which makes it slightly faster than Supertest for Fastify apps.

See Also