ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeIntegration Testing Your Node.js API: The Setup That Actually Catches Bugs

Integration Testing Your Node.js API: The Setup That Actually Catches Bugs

Vitest, Supertest, transaction-based test isolation, and the factory pattern that makes test data effortless

ZyVOP
ZyVOP
Senior Developer
May 27, 2026
7 min read
Integration Testing Your Node.js API: The Setup That Actually Catches Bugs
#Express integration tests#Vitest Supertest#integration test factories#Jest Supertest TypeScript#test database rollback#Node.js integration testing#Node.js API testing 2026

Unit tests are fast and easy to write. They are also very good at telling you your functions work in isolation while your API is broken in production.

The test that would have caught the bug is usually an integration test — one that hits a real endpoint, talks to a real database, and verifies the entire request/response cycle. These are the tests that validate what unit tests cannot: routing logic, middleware execution order, request validation, authentication checks, error response shapes, and database interactions that only fail under real data constraints.

This guide builds a production-grade integration test setup using Vitest and Supertest, with a real Postgres database, proper test isolation, and patterns that scale to a large test suite without becoming slow or brittle.


Why Vitest Over Jest in 2026

Integration tests verify that multiple modules or services work together correctly. For most Node.js backends in 2026, Vitest is the better testing framework choice for new projects:

  • Native ESM support without configuration gymnastics

  • TypeScript support out of the box — no ts-jest or babel-jest

  • Faster than Jest on cold starts due to Vite's dependency pre-bundling

  • Jest-compatible API — describe, it, expect, vi.mock — minimal migration friction

  • Watch mode that re-runs only affected tests

If your project already uses Jest, the patterns here apply equally — swap vi for jest and vitest imports for @jest/globals.


Setup

npm install -D vitest supertest @types/supertest
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals:     true,              // No need to import describe/it/expect
    environment: 'node',
    setupFiles:  ['./src/tests/setup.ts'],
    testTimeout: 30_000,            // Integration tests can be slow
    pool:        'forks',           // Separate process per test file — true isolation
    poolOptions: {
      forks: { singleFork: true },  // One process for all tests — shared DB connection
    },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      exclude:  ['**/node_modules/**', '**/tests/**'],
    },
  },
});

The Test Database Setup

The most important decision in integration testing is how you manage test data. The wrong approach (sharing state between tests) produces flaky tests that pass in isolation and fail in a suite.

The right approach: each test runs in a transaction that is rolled back at the end. No cleanup code, no seed/teardown overhead, complete isolation.

// src/tests/setup.ts
import { Pool } from 'pg';
import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest';

// Separate test database — never run tests against production
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL
  || 'postgresql://testuser:testpass@localhost:5432/testdb';

export const testPool = new Pool({ connectionString: TEST_DATABASE_URL });

// Shared client — holds the transaction open across the test
let testClient: any;

beforeAll(async () => {
  // Run migrations on the test database before any tests
  // Use your migration tool of choice
  await runMigrations(TEST_DATABASE_URL);
});

beforeEach(async () => {
  testClient = await testPool.connect();
  // Start a transaction — all queries in this test run inside it
  await testClient.query('BEGIN');
});

afterEach(async () => {
  // Rollback — undoes every INSERT/UPDATE/DELETE from the test
  await testClient.query('ROLLBACK');
  testClient.release();
});

afterAll(async () => {
  await testPool.end();
});

// Export so tests can use the same transactional client
export { testClient };

For this to work, your app needs to use the test client during tests. Inject the database via a module or environment variable:

// src/lib/db.ts
import { Pool } from 'pg';

let _pool: Pool | null = null;

export function getPool(): Pool {
  if (!_pool) {
    _pool = new Pool({ connectionString: process.env.DATABASE_URL });
  }
  return _pool;
}

// Allow tests to inject a transactional client
export function setPool(pool: Pool) {
  _pool = pool;
}

Building the Test App

Supertest needs your Express app instance — not a running server. Separate your app definition from the server startup:

// src/app.ts — just the Express app, no listen()
import express from 'express';
import { requestLogger } from './middleware/requestLogger';
import { errorHandler } from './middleware/errorHandler';
import router from './routes';

const app = express();
app.use(express.json());
app.use(requestLogger);
app.use('/api', router);
app.use(errorHandler);

export default app;

// src/server.ts — starts the actual server
import app from './app';
app.listen(3000);
// src/tests/helpers/testApp.ts
import supertest from 'supertest';
import app from '../../app';
import { setPool } from '../../lib/db';
import { testPool, testClient } from '../setup';

export function createTestApp() {
  // Inject the transactional test client
  setPool(testClient);
  return supertest(app);
}

Writing Integration Tests

Authentication Routes

// src/tests/integration/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestApp } from '../helpers/testApp';
import { createTestUser, createTestTenant } from '../helpers/factories';

describe('POST /api/auth/login', () => {
  let request: ReturnType<typeof createTestApp>;

  beforeEach(() => {
    request = createTestApp();
  });

  it('returns 200 and tokens with valid credentials', async () => {
    const { user, tenant } = await createTestUser({
      email:    '[email protected]',
      password: 'ValidPassword123!',
    });

    const response = await request
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'ValidPassword123!' });

    expect(response.status).toBe(200);
    expect(response.body).toMatchObject({
      accessToken: expect.any(String),
      user: {
        id:    user.id,
        email: '[email protected]',
      },
    });
    // Refresh token should be in httpOnly cookie, not body
    expect(response.body.refreshToken).toBeUndefined();
    expect(response.headers['set-cookie']).toBeDefined();
  });

  it('returns 401 with wrong password', async () => {
    await createTestUser({ email: '[email protected]', password: 'correct' });

    const response = await request
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'wrong' });

    expect(response.status).toBe(401);
    expect(response.body.error.code).toBe('AUTHENTICATION_ERROR');
  });

  it('returns 401 for non-existent email', async () => {
    const response = await request
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'anything' });

    expect(response.status).toBe(401);
    // Same error as wrong password — do not reveal whether email exists
    expect(response.body.error.code).toBe('AUTHENTICATION_ERROR');
  });

  it('returns 400 when email is missing', async () => {
    const response = await request
      .post('/api/auth/login')
      .send({ password: 'ValidPassword123!' });

    expect(response.status).toBe(400);
    expect(response.body.error.code).toBe('VALIDATION_ERROR');
  });

  it('rate limits after 5 failed attempts', async () => {
    await createTestUser({ email: '[email protected]', password: 'correct' });

    // Make 5 failed attempts
    for (let i = 0; i < 5; i++) {
      await request
        .post('/api/auth/login')
        .send({ email: '[email protected]', password: 'wrong' });
    }

    // 6th attempt should be rate limited
    const response = await request
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'wrong' });

    expect(response.status).toBe(429);
  });
});

Protected Routes

// src/tests/helpers/auth.ts
import supertest from 'supertest';
import { createTestUser } from './factories';
import app from '../../app';

export async function getAuthenticatedRequest(overrides = {}) {
  const request = supertest(app);
  const { user, tenant } = await createTestUser(overrides);

  const loginResponse = await request
    .post('/api/auth/login')
    .send({ email: user.email, password: 'TestPassword123!' });

  const { accessToken } = loginResponse.body;

  // Return a request builder pre-configured with auth header
  return {
    request,
    user,
    tenant,
    authHeader: `Bearer ${accessToken}`,
    get:    (url: string) => request.get(url).set('Authorization', `Bearer ${accessToken}`),
    post:   (url: string) => request.post(url).set('Authorization', `Bearer ${accessToken}`),
    put:    (url: string) => request.put(url).set('Authorization', `Bearer ${accessToken}`),
    delete: (url: string) => request.delete(url).set('Authorization', `Bearer ${accessToken}`),
  };
}
// src/tests/integration/orders.test.ts
import { describe, it, expect } from 'vitest';
import { getAuthenticatedRequest } from '../helpers/auth';
import { createTestProduct } from '../helpers/factories';

describe('POST /api/orders', () => {
  it('creates an order and returns 201', async () => {
    const { post, user, tenant } = await getAuthenticatedRequest();
    const product = await createTestProduct({ tenantId: tenant.id, stock: 10, price: 29.99 });

    const response = await post('/api/orders').send({
      items: [{ productId: product.id, quantity: 2 }],
      shippingAddress: {
        line1:    '123 Main St',
        city:     'San Francisco',
        state:    'CA',
        postcode: '94105',
        country:  'US',
      },
    });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id:     expect.any(String),
      status: 'pending',
      total:  '59.98',
    });
  });

  it('returns 422 when product is out of stock', async () => {
    const { post, tenant } = await getAuthenticatedRequest();
    const product = await createTestProduct({ tenantId: tenant.id, stock: 0 });

    const response = await post('/api/orders').send({
      items: [{ productId: product.id, quantity: 1 }],
    });

    expect(response.status).toBe(422);
    expect(response.body.error.code).toBe('UNPROCESSABLE');
  });

  it('returns 401 without auth token', async () => {
    const request = supertest(app);
    const response = await request.post('/api/orders').send({});
    expect(response.status).toBe(401);
  });
});

Test Factories

// src/tests/helpers/factories.ts
import bcrypt from 'bcryptjs';
import { testClient } from '../setup';
import { randomUUID } from 'crypto';

export async function createTestTenant(overrides = {}) {
  const tenant = {
    id:   randomUUID(),
    slug: `test-${randomUUID().slice(0, 8)}`,
    name: 'Test Tenant',
    plan: 'pro',
    ...overrides,
  };

  await testClient.query(
    `INSERT INTO tenants (id, slug, name, plan) VALUES ($1, $2, $3, $4)`,
    [tenant.id, tenant.slug, tenant.name, tenant.plan]
  );

  return tenant;
}

export async function createTestUser(overrides: any = {}) {
  const tenant = overrides.tenant || await createTestTenant();
  const passwordHash = await bcrypt.hash(overrides.password || 'TestPassword123!', 10);

  const user = {
    id:       randomUUID(),
    tenantId: tenant.id,
    email:    overrides.email || `user-${randomUUID().slice(0, 8)}@test.com`,
    fullName: overrides.fullName || 'Test User',
    role:     overrides.role || 'member',
  };

  await testClient.query(
    `INSERT INTO users (id, tenant_id, email, full_name, role, password_hash)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [user.id, user.tenantId, user.email, user.fullName, user.role, passwordHash]
  );

  return { user, tenant };
}

export async function createTestProduct(overrides: any = {}) {
  const product = {
    id:          randomUUID(),
    tenantId:    overrides.tenantId,
    name:        overrides.name || 'Test Product',
    price:       overrides.price || 9.99,
    stock:       overrides.stock ?? 100,
    active:      true,
  };

  await testClient.query(
    `INSERT INTO products (id, tenant_id, name, price, stock, active)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [product.id, product.tenantId, product.name, product.price, product.stock, product.active]
  );

  return product;
}

Running Tests in CI

# .github/workflows/ci.yml (test job)
services:
  postgres:
    image: postgres:16-alpine
    env:
      POSTGRES_USER:     testuser
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB:       testdb
    ports:
      - 5432:5432
    options: >-
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

steps:
  - name: Run tests
    run: npm test
    env:
      TEST_DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
      NODE_ENV: test
      JWT_PRIVATE_KEY: ${{ secrets.TEST_JWT_PRIVATE_KEY }}

What to Test and What Not To

Testing depth should match business risk, not trend pressure. Unit tests do not validate routing, middleware, serialization, and error envelope behavior.

Write integration tests for:

  • Authentication flows end-to-end

  • Authorization — verify users cannot access other tenants' data

  • Request validation — confirm invalid inputs return correct error shapes

  • Business logic with database side effects

  • Error response shapes — confirm your error handler works as designed

  • Rate limiting behavior

Do not write integration tests for:

  • Pure utility functions (test these as unit tests — faster)

  • Third-party API calls (mock at the HTTP boundary with msw or nock)

  • Things already covered by your ORM or framework

The rule: if tests pass while real integration repeatedly fails, mocking is likely hiding boundary problems. Mock at external seams, not core behavior.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1