Skip to content
← Testing · beginner · 10 min · 03 / 07

Integration Testing

Testing real boundaries — database queries, HTTP handlers, message queues — without mocking what matters.

integration testingdatabase testingHTTP testingTestcontainerssupertestmigrations

Real-World Analogy

Testing a new employee’s first day working with real coworkers, not role-playing it in a training room. The training (unit tests) teaches the job; the real first day reveals whether the knowledge translates — whether they can actually use the filing system, talk to the right people, follow the actual workflows.

Why Integration Tests Need Real Dependencies

// This test will always pass even if your SQL is wrong
const mockDb = { query: jest.fn().mockResolvedValue({ rows: [] }) };
await createUser(mockDb, { email: 'test@example.com' });
// mockDb.query was called — but did the INSERT work?
// Does the email uniqueness constraint fire? No.
// Does the trigger that creates a profile row run? No.
// You tested that your code calls query(). That's it.

// A real test DB catches:
// - Syntax errors in SQL
// - Wrong column names
// - Constraint violations
// - Missing indexes causing slow queries
// - Trigger side effects
// - Transaction behavior

Database Integration Tests

Use a real database. Roll back after each test:

// src/test/setup.ts
import { Pool } from 'pg';
import { runMigrations } from '../db/migrate';

export const testDb = new Pool({
  connectionString: process.env.DATABASE_URL ?? 'postgres://test:test@localhost:5432/testdb',
});

// Run migrations once before all tests
beforeAll(async () => {
  await runMigrations(testDb);
});

// Clean up after all tests
afterAll(async () => {
  await testDb.end();
});
// src/users/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { testDb } from '../test/setup';
import { createUser, getUserByEmail } from './users';

describe('users integration', () => {
  beforeEach(async () => {
    await testDb.query('BEGIN');
  });

  afterEach(async () => {
    await testDb.query('ROLLBACK');
  });

  it('creates a user and retrieves by email', async () => {
    const email = 'alice@example.com';

    const user = await createUser(testDb, { email, name: 'Alice' });

    expect(user.id).toBeDefined();
    expect(user.email).toBe(email);

    const found = await getUserByEmail(testDb, email);
    expect(found?.id).toBe(user.id);
  });

  it('enforces unique email constraint', async () => {
    await createUser(testDb, { email: 'alice@example.com', name: 'Alice' });

    await expect(
      createUser(testDb, { email: 'alice@example.com', name: 'Other' })
    ).rejects.toThrow(/unique/i);
  });

  it('returns null for nonexistent email', async () => {
    const result = await getUserByEmail(testDb, 'nobody@example.com');
    expect(result).toBeNull();
  });
});

Transaction rollback means tests don’t interfere with each other — each test starts with a clean slate and leaves no trace.

Testcontainers — Spin Up Dependencies in CI

Testcontainers starts real Docker containers for your test run — no pre-configured test DB needed:

npm install -D @testcontainers/postgresql
// src/test/db-container.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { runMigrations } from '../db/migrate';

let pool: Pool;

export async function startTestDatabase() {
  const container = await new PostgreSqlContainer('postgres:16')
    .withDatabase('testdb')
    .start();

  pool = new Pool({ connectionString: container.getConnectionUri() });

  await runMigrations(pool);

  return {
    pool,
    stop: async () => {
      await pool.end();
      await container.stop();
    },
  };
}
// vitest.config.ts — global setup
export default defineConfig({
  test: {
    globalSetup: './src/test/global-setup.ts',
  },
});

// src/test/global-setup.ts
import { startTestDatabase } from './db-container';

export async function setup() {
  const { pool, stop } = await startTestDatabase();
  process.env.DATABASE_URL = pool.options.connectionString;
  return stop;  // Vitest calls this as teardown
}

HTTP Integration Tests

Test your HTTP layer end-to-end — real server, real DB, real middleware:

npm install -D supertest @types/supertest
// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import { app } from './app';
import { testDb } from './test/setup';

const request = supertest(app);

describe('POST /users', () => {
  it('creates user and returns 201', async () => {
    const res = await request
      .post('/users')
      .send({ email: 'bob@example.com', name: 'Bob' })
      .set('Accept', 'application/json');

    expect(res.status).toBe(201);
    expect(res.body.id).toBeDefined();
    expect(res.body.email).toBe('bob@example.com');
  });

  it('returns 400 for duplicate email', async () => {
    await request.post('/users').send({ email: 'dup@example.com', name: 'First' });

    const res = await request
      .post('/users')
      .send({ email: 'dup@example.com', name: 'Second' });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/already exists/i);
  });

  it('returns 422 for invalid email', async () => {
    const res = await request
      .post('/users')
      .send({ email: 'not-an-email', name: 'Bob' });

    expect(res.status).toBe(422);
    expect(res.body.errors).toContainEqual(
      expect.objectContaining({ field: 'email' })
    );
  });
});

describe('GET /users/:id', () => {
  it('returns 404 for missing user', async () => {
    const res = await request.get('/users/00000000-0000-0000-0000-000000000000');
    expect(res.status).toBe(404);
  });
});

Testing Authenticated Routes

// Helper: create a test user and get auth token
async function authenticatedRequest(overrides: Partial<User> = {}) {
  const user = await createUser(testDb, {
    email: `test-${randomUUID()}@example.com`,
    ...overrides,
  });
  const token = signJwt({ userId: user.id, role: user.role });

  return {
    user,
    agent: supertest(app).set('Authorization', `Bearer ${token}`),
  };
}

// Use in tests
it('allows admin to delete users', async () => {
  const { agent } = await authenticatedRequest({ role: 'admin' });
  const target = await createUser(testDb, { email: 'victim@example.com' });

  const res = await agent.delete(`/users/${target.id}`);
  expect(res.status).toBe(204);
});

it('blocks standard user from deleting', async () => {
  const { agent } = await authenticatedRequest({ role: 'standard' });
  const target = await createUser(testDb, { email: 'victim@example.com' });

  const res = await agent.delete(`/users/${target.id}`);
  expect(res.status).toBe(403);
});

Testing External HTTP APIs

For external services you don’t control, use msw (Mock Service Worker) to intercept HTTP at the network level — not at the import level:

npm install -D msw
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.post('https://api.stripe.com/v1/charges', () => {
    return HttpResponse.json({
      id: 'ch_test123',
      status: 'succeeded',
      amount: 100_00,
    });
  }),

  http.post('https://api.sendgrid.com/v3/mail/send', () => {
    return new HttpResponse(null, { status: 202 });
  }),
];

// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// vitest.config.ts
export default defineConfig({
  test: {
    setupFiles: ['./src/test/msw-setup.ts'],
  },
});

// src/test/msw-setup.ts
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override for specific test
it('handles Stripe payment failure', async () => {
  server.use(
    http.post('https://api.stripe.com/v1/charges', () => {
      return HttpResponse.json({ error: { code: 'card_declined' } }, { status: 402 });
    })
  );

  const result = await processPayment({ amount: 50_00, card: 'tok_declined' });
  expect(result.success).toBe(false);
  expect(result.error).toBe('card_declined');
});

Testing Message Queue Consumers

// Test the consumer function directly — no need to run a real queue
import { processOrderEvent } from './order-consumer';

it('marks order as shipped on ShipmentCreated event', async () => {
  const orderId = await createOrder(testDb, { userId: 'user-1', totalCents: 50_00 });

  await processOrderEvent(testDb, {
    type: 'ShipmentCreated',
    orderId,
    trackingNumber: 'TRACK123',
  });

  const order = await getOrder(testDb, orderId);
  expect(order.status).toBe('shipped');
  expect(order.trackingNumber).toBe('TRACK123');
});

// For testing that the right events are published, spy on the publisher
it('publishes OrderShipped event after processing', async () => {
  const publish = vi.fn();
  const orderId = await createOrder(testDb, { userId: 'user-1', totalCents: 50_00 });

  await processOrderEvent(testDb, { type: 'ShipmentCreated', orderId }, { publish });

  expect(publish).toHaveBeenCalledWith(
    'orders',
    expect.objectContaining({ type: 'OrderShipped', orderId })
  );
});

Database Seeding Helpers

// src/test/factories.ts
import { Pool } from 'pg';
import { randomUUID } from 'crypto';

export async function seedUser(db: Pool, overrides: Partial<User> = {}): Promise<User> {
  const { rows } = await db.query(
    `INSERT INTO users (id, email, name, role, created_at)
     VALUES ($1, $2, $3, $4, NOW())
     RETURNING *`,
    [
      randomUUID(),
      overrides.email ?? `user-${randomUUID()}@example.com`,
      overrides.name ?? 'Test User',
      overrides.role ?? 'standard',
    ]
  );
  return rows[0];
}

export async function seedProduct(db: Pool, overrides: Partial<Product> = {}): Promise<Product> {
  const { rows } = await db.query(
    `INSERT INTO products (id, name, price_cents, in_stock)
     VALUES ($1, $2, $3, $4) RETURNING *`,
    [
      randomUUID(),
      overrides.name ?? 'Test Product',
      overrides.priceCents ?? 10_00,
      overrides.inStock ?? true,
    ]
  );
  return rows[0];
}

Factories keep test setup readable and maintainable — when schema changes, fix the factory once.