Skip to content

Integration Testing - Real Dependencies , Real Confidence

Table of Contents


Testing with Real Dependencies : Databases , APIs

Unit tests tell you if your logic is correct. Integration tests tell you if your system works. The difference is massive - a perfect unit test can pass while the database connection string is wrong , the API returns a different shape than expected , or the serialization format has a typo

Integration tests use real (or real-enough) dependencies:

  • A real database (or testcontainers)
  • An HTTP server you control (supertest)
  • Test fixtures with actual data shapes
  • Real serialization/deserialization paths

The rule: if it touches a network boundary or reads from disk , test it as close to reality as possible

Supertest for HTTP Integration Tests

Supertest takes your Express (or any Node HTTP) app and fires real HTTP requests at it without binding to a port. No ports , no firewall issues , no zombie server processes

npm install --save-dev supertest
const request = require('supertest')
const app = require('../src/app')

describe('GET /api/users/:id', () => {
  it('returns user data for valid id', async () => {
    const res = await request(app)
      .get('/api/users/1')
      .expect(200)

    expect(res.body).toHaveProperty('id', '1')
    expect(res.body).toHaveProperty('name')
    expect(res.body).toHaveProperty('email')
  })

  it('returns 404 for nonexistent user', async () => {
    const res = await request(app)
      .get('/api/users/99999')
      .expect(404)

    expect(res.body).toHaveProperty('error')
  })

  it('requires authentication', async () => {
    await request(app)
      .get('/api/users/1')
      .expect(401) // no auth token
  })
})

Testing authenticated routes:

describe('POST /api/orders', () => {
  let authToken

  beforeAll(async () => {
    const res = await request(app)
      .post('/api/login')
      .send({ username: 'testuser', password: 'testpass' })
    authToken = res.body.token
  })

  it('creates order with valid auth', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [{ productId: 'p1', quantity: 2 }] })
      .expect(201)

    expect(res.body).toHaveProperty('id')
    expect(res.body.items).toHaveLength(1)
  })

  it('rejects order without auth', async () => {
    await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: 'p1', quantity: 2 }] })
      .expect(401)
  })
})

Test Fixtures and Factories

Fixtures are pre-built data structures used across tests. Factories generate them dynamically

// test/fixtures/users.js
const fixtures = {
  standardUser: {
    id: '1',
    name: 'ali',
    email: 'ali@example.com',
    role: 'user',
    createdAt: new Date('2026-01-01'),
  },
  adminUser: {
    id: '2',
    name: 'omar',
    email: 'omar@example.com',
    role: 'admin',
    createdAt: new Date('2026-01-01'),
  },
  inactiveUser: {
    id: '3',
    name: 'khaled',
    email: 'khaled@example.com',
    role: 'user',
    active: false,
    createdAt: new Date('2025-06-01'),
  },
}

module.exports = fixtures
// test/factories/userFactory.js
let counter = 0

function buildUser(overrides = {}) {
  counter++
  return {
    id: `user-${counter}`,
    name: `Test User ${counter}`,
    email: `test${counter}@example.com`,
    role: 'user',
    active: true,
    createdAt: new Date(),
    ...overrides,
  }
}

module.exports = { buildUser }
// test using factory
const { buildUser } = require('./factories/userFactory')

test('creates user with generated data', async () => {
  const userData = buildUser({ role: 'admin' })

  const res = await request(app)
    .post('/api/users')
    .send(userData)
    .expect(201)

  expect(res.body.name).toBe(userData.name)
  expect(res.body.role).toBe('admin')
})

Warning: Never commit static test data that references real users , real email addresses , or real credentials. Faker libraries exist for a reason

Database Test Setup/Teardown : beforeAll , afterAll

Integration tests against a database need setup and cleanup hooks

const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()

beforeAll(async () => {
  // seed test data
  await prisma.user.create({
    data: {
      id: 'test-user-1',
      name: 'mohsen',
      email: 'mohsen@test.com',
      password: '$2b$10$...', // pre-hashed
    },
  })
})

afterAll(async () => {
  // clean up test data
  await prisma.user.deleteMany({
    where: { email: { endsWith: '@test.com' } }
  })
  await prisma.$disconnect()
})

// each test runs against seeded data
describe('POST /api/login', () => {
  it('returns token for valid credentials', async () => {
    const res = await request(app)
      .post('/api/login')
      .send({ email: 'mohsen@test.com', password: 'test123' })
      .expect(200)

    expect(res.body).toHaveProperty('token')
  })

  it('rejects wrong password', async () => {
    await request(app)
      .post('/api/login')
      .send({ email: 'mohsen@test.com', password: 'wrongpass' })
      .expect(401)
  })
})

Transaction rollback approach for cleaner isolation (faster than cleaning tables):

const { PrismaClient } = require('@prisma/client')

let prisma

beforeEach(async () => {
  prisma = new PrismaClient()
  await prisma.$executeRaw('BEGIN')
})

afterEach(async () => {
  await prisma.$executeRaw('ROLLBACK')
  await prisma.$disconnect()
})

Testcontainers for Disposable Services

Real integration means real infrastructure - PostgreSQL , Redis , RabbitMQ. Testcontainers manages Docker containers per test run

const { PostgreSqlContainer } = require('testcontainers')
const { Client } = require('pg')

let container
let client

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine')
    .withDatabase('testdb')
    .withUsername('test')
    .withPassword('testpass')
    .start()

  client = new Client({
    connectionString: container.getConnectionUri(),
  })
  await client.connect()

  // run migrations
  await client.query(`
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL
    )
  `)
}, 30000) // containers need 30s timeout

afterAll(async () => {
  await client.end()
  await container.stop()
})

test('inserts and retrieves user', async () => {
  await client.query('INSERT INTO users (name, email) VALUES ($1, $2)', ['ali', 'ali@test.com'])
  const res = await client.query("SELECT * FROM users WHERE email = 'ali@test.com'")
  expect(res.rows[0].name).toBe('ali')
})

Testcontainers pulls the image on first run (adds latency) but caches it after. Run your integration tests in CI with --runInBand (Jest) to avoid container port conflicts

Security : Integration Tests Catch Auth/AuthZ Issues

Integration tests expose authorization bugs that unit tests miss

describe('access control integration', () => {
  let userToken
  let adminToken

  beforeAll(async () => {
    userToken = (await loginAs('normaluser')).body.token
    adminToken = (await loginAs('adminuser')).body.token
  })

  it('user cannot delete other users', async () => {
    await request(app)
      .delete('/api/users/adminuser')
      .set('Authorization', `Bearer ${userToken}`)
      .expect(403) // forbidden - not admin
  })

  it('admin can delete users', async () => {
    await request(app)
      .delete('/api/users/normaluser')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200)
  })

  it('unauthenticated requests blocked', async () => {
    await request(app)
      .get('/api/users/1')
      .expect(401)
  })

  it('CSRF token required for mutation endpoints', async () => {
    await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${userToken}`)
      // no CSRF token
      .send({ items: [] })
      .expect(403)
  })
})

prerequisites

test_03_mocking.md - mocking strategies , sinon , testcontainers basics

next -> test_05_debugging.md