Integration Testing
Testing how pieces work together - API endpoints, database interactions, and real-world scenarios.
7 min read
Beyond Unit Tests#
Unit tests verify individual functions work. Integration tests verify they work together.
javascript
// Unit test: does this function hash passwords?
test('hashPassword returns hashed string', async () => {
const hash = await hashPassword('secret');
expect(hash).not.toBe('secret');
});
// Integration test: does registration actually work?
test('POST /register creates user with hashed password', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({ email: 'test@test.com', password: 'secret' });
expect(response.status).toBe(201);
const user = await User.findOne({ email: 'test@test.com' });
expect(user.password).not.toBe('secret'); // Hashed in DB
});
Setting Up Integration Tests#
HTTP Testing with Supertest#
bash
npm install -D supertest
javascript
// tests/integration/setup.js
import { app } from '../../src/app.js';
import request from 'supertest';
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
let mongoServer;
export { request, app };
export async function setupTestDB() {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
}
export async function teardownTestDB() {
await mongoose.disconnect();
await mongoServer.stop();
}
export async function clearDatabase() {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
}
Test Database Options#
Option 1: In-Memory MongoDB
bash
npm install -D mongodb-memory-server
javascript
import { MongoMemoryServer } from 'mongodb-memory-server';
let mongod;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
Pros: Fast, isolated, no setup Cons: Behavior might differ slightly from real MongoDB
Option 2: Docker Test Database
yaml
# docker-compose.test.yml
services:
mongodb-test:
image: mongo:7
ports:
- "27018:27017"
javascript
// Connect to test database
beforeAll(async () => {
await mongoose.connect('mongodb://localhost:27018/test');
});
Pros: Real MongoDB, exact production behavior Cons: Slower, requires Docker
Option 3: Separate Test Database
javascript
// Use different database based on NODE_ENV
const dbUri = process.env.NODE_ENV === 'test'
? process.env.TEST_MONGODB_URI
: process.env.MONGODB_URI;
Pros: Simple, real database Cons: Shared state if not careful, slower
Testing API Endpoints#
Basic API Tests#
javascript
// tests/integration/users.test.js
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';
import { setupTestDB, teardownTestDB, clearDatabase } from './setup.js';
import { User } from '../../src/models/User.js';
describe('Users API', () => {
beforeAll(async () => {
await setupTestDB();
});
afterAll(async () => {
await teardownTestDB();
});
beforeEach(async () => {
await clearDatabase();
});
describe('GET /api/users', () => {
it('returns empty array when no users', async () => {
const response = await request(app).get('/api/users');
expect(response.status).toBe(200);
expect(response.body.data).toEqual([]);
});
it('returns all users', async () => {
await User.create([
{ email: 'user1@test.com', password: 'password1', name: 'User 1' },
{ email: 'user2@test.com', password: 'password2', name: 'User 2' },
]);
const response = await request(app).get('/api/users');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(2);
});
it('supports pagination', async () => {
// Create 25 users
const users = Array.from({ length: 25 }, (_, i) => ({
email: `user${i}@test.com`,
password: 'password',
name: `User ${i}`,
}));
await User.create(users);
const response = await request(app)
.get('/api/users')
.query({ page: 2, limit: 10 });
expect(response.body.data).toHaveLength(10);
expect(response.body.pagination.page).toBe(2);
});
});
describe('POST /api/users', () => {
it('creates a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'new@test.com',
password: 'password123',
name: 'New User',
});
expect(response.status).toBe(201);
expect(response.body.data.email).toBe('new@test.com');
expect(response.body.data.password).toBeUndefined(); // Not returned
// Verify in database
const user = await User.findOne({ email: 'new@test.com' });
expect(user).not.toBeNull();
});
it('returns 400 for invalid data', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid-email' });
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('returns 409 for duplicate email', async () => {
await User.create({
email: 'existing@test.com',
password: 'password',
name: 'Existing',
});
const response = await request(app)
.post('/api/users')
.send({
email: 'existing@test.com',
password: 'password123',
name: 'Duplicate',
});
expect(response.status).toBe(409);
});
});
});
Testing Authentication#
javascript
// tests/integration/auth.test.js
describe('Auth API', () => {
describe('POST /api/auth/register', () => {
it('registers new user and returns tokens', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
email: 'new@test.com',
password: 'Password123!',
name: 'New User',
});
expect(response.status).toBe(201);
expect(response.body.data.user.email).toBe('new@test.com');
expect(response.body.data.tokens.accessToken).toBeDefined();
expect(response.body.data.tokens.refreshToken).toBeDefined();
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
await User.create({
email: 'test@test.com',
password: 'Password123!', // Will be hashed by model
name: 'Test User',
});
});
it('returns tokens for valid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@test.com',
password: 'Password123!',
});
expect(response.status).toBe(200);
expect(response.body.data.tokens.accessToken).toBeDefined();
});
it('returns 401 for invalid password', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@test.com',
password: 'wrongpassword',
});
expect(response.status).toBe(401);
});
});
});
Testing Protected Routes#
javascript
describe('Protected Routes', () => {
let authToken;
let testUser;
beforeEach(async () => {
// Create user and get token
testUser = await User.create({
email: 'test@test.com',
password: 'Password123!',
name: 'Test User',
});
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'test@test.com', password: 'Password123!' });
authToken = loginResponse.body.data.tokens.accessToken;
});
describe('GET /api/users/me', () => {
it('returns current user when authenticated', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.data.email).toBe('test@test.com');
});
it('returns 401 without token', async () => {
const response = await request(app).get('/api/users/me');
expect(response.status).toBe(401);
});
it('returns 401 with invalid token', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(401);
});
});
describe('PATCH /api/users/:id', () => {
it('allows user to update own profile', async () => {
const response = await request(app)
.patch(`/api/users/${testUser._id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated Name' });
expect(response.status).toBe(200);
expect(response.body.data.name).toBe('Updated Name');
});
it('returns 403 when updating other user', async () => {
const otherUser = await User.create({
email: 'other@test.com',
password: 'password',
name: 'Other',
});
const response = await request(app)
.patch(`/api/users/${otherUser._id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Hacked' });
expect(response.status).toBe(403);
});
});
});
Test Helpers#
Authentication Helper#
javascript
// tests/helpers/auth.js
import request from 'supertest';
import { app } from '../../src/app.js';
import { User } from '../../src/models/User.js';
export async function createTestUser(overrides = {}) {
return User.create({
email: 'test@test.com',
password: 'Password123!',
name: 'Test User',
...overrides,
});
}
export async function getAuthToken(email = 'test@test.com', password = 'Password123!') {
const response = await request(app)
.post('/api/auth/login')
.send({ email, password });
return response.body.data.tokens.accessToken;
}
export function authenticatedRequest(app, token) {
return {
get: (url) => request(app).get(url).set('Authorization', `Bearer ${token}`),
post: (url) => request(app).post(url).set('Authorization', `Bearer ${token}`),
patch: (url) => request(app).patch(url).set('Authorization', `Bearer ${token}`),
delete: (url) => request(app).delete(url).set('Authorization', `Bearer ${token}`),
};
}
javascript
// Usage
import { createTestUser, getAuthToken, authenticatedRequest } from '../helpers/auth.js';
beforeEach(async () => {
await createTestUser();
});
it('fetches profile', async () => {
const token = await getAuthToken();
const api = authenticatedRequest(app, token);
const response = await api.get('/api/users/me');
expect(response.status).toBe(200);
});
Factory Functions#
javascript
// tests/factories/user.js
import { User } from '../../src/models/User.js';
let counter = 0;
export function buildUser(overrides = {}) {
counter++;
return {
email: `user${counter}@test.com`,
password: 'Password123!',
name: `Test User ${counter}`,
...overrides,
};
}
export async function createUser(overrides = {}) {
return User.create(buildUser(overrides));
}
export async function createUsers(count, overrides = {}) {
const users = Array.from({ length: count }, () => buildUser(overrides));
return User.create(users);
}
Running Integration Tests#
json
// package.json
{
"scripts": {
"test": "vitest run",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
javascript
// vitest.config.js
export default {
test: {
// Run integration tests sequentially (they share DB)
sequence: {
shuffle: false,
},
// Longer timeout for integration tests
testTimeout: 10000,
},
};
Key Takeaways#
- Use a test database - In-memory for speed, real for accuracy
- Clear between tests - Each test starts fresh
- Test real HTTP - Supertest hits your actual routes
- Create helpers - Authentication, factories save time
- Test both happy and error paths - 200s and 400s
The Balance
Integration tests are slower than unit tests but catch more bugs. Use unit tests for logic, integration tests for flows. You need both.
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.