ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

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

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
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

#Node.js integration testing#Vitest Supertest#Express integration tests#Jest Supertest TypeScript#test database rollback#integration test factories#Node.js API testing 2026
Z
ZyVOP

Senior Developer

May 27, 2026
9 min read
3 views
Integration Testing Your Node.js API: The Setup That Actually Catches Bugs

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:    'test@example.com',
      password: 'ValidPassword123!',
    });

    const response = await request
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'ValidPassword123!' });

    expect(response.status).toBe(200);
    expect(response.body).toMatchObject({
      accessToken: expect.any(String),
      user: {
        id:    user.id,
        email: 'test@example.com',
      },
    });
    // 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: 'test@example.com', password: 'correct' });

    const response = await request
      .post('/api/auth/login')
      .send({ email: 'test@example.com', 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: 'nobody@example.com', 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: 'test@example.com', password: 'correct' });

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

    // 6th attempt should be rate limited
    const response = await request
      .post('/api/auth/login')
      .send({ email: 'test@example.com', 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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design