Integration Testing - Real Dependencies , Real Confidence¶
Table of Contents¶
- Testing with Real Dependencies : Databases , APIs
- Supertest for HTTP Integration Tests
- Test Fixtures and Factories
- Database Test Setup/Teardown : beforeAll , afterAll
- Testcontainers for Disposable Services
- Security : Integration Tests Catch Auth/AuthZ Issues
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