Integration tests verify that Express routes, middleware, Mongoose models, and the database work together correctly — testing the entire HTTP request-response cycle. Where unit tests mock dependencies, integration tests let real Mongoose queries run against a real (in-memory) MongoDB instance. This catches a different category of bugs: query construction errors, missing indexes that cause timeout, middleware interaction issues, and validation errors that only trigger on actual Mongoose save. Supertest is the standard library for making HTTP requests to an Express app in tests without starting a real server.
Supertest API
| Method | Purpose | Example |
|---|---|---|
request(app).get(path) |
GET request to the Express app | request(app).get('/api/v1/tasks') |
.post(path).send(body) |
POST with JSON body | .post('/api/v1/tasks').send({ title: 'Test' }) |
.set(header, value) |
Set a request header | .set('Authorization', 'Bearer ' + token) |
.expect(status) |
Assert HTTP status code | .expect(201) |
.expect(field, value) |
Assert JSON body field | .expect('body.success', true) |
const res = await req |
Get full response object | expect(res.body.data.title).toBe('Test') |
.attach(field, file) |
Attach a file for upload | .attach('file', Buffer.from('data'), 'test.pdf') |
app object directly — it starts an HTTP server on a random available port internally, makes the request, and tears down the server. You never need to listen on a port in your test files. This means the same app.js you use in production powers your tests — if you accidentally call app.listen() in a test, you will get port conflict errors. Export only the Express app object from app.js, and start the server in a separate server.js.it('should require auth', () => request(app).get('/api/v1/tasks').expect(401)) test per protected endpoint is cheap to write and high-value.afterEach setup from Lesson 1 to clear all collections, or use a per-test cleanup strategy. Never assert on absolute counts — assert on the data structure and the specific items your test created.Complete Integration Test Examples
// src/__tests__/integration/task.routes.test.js
const request = require('supertest');
const app = require('../../app');
const Task = require('../../models/task.model');
const { createUser, createAdmin, generateToken } = require('../factories/user.factory');
describe('Task Routes', () => {
let user, adminUser, userToken, adminToken;
beforeEach(async () => {
user = await createUser();
adminUser = await createAdmin();
userToken = generateToken(user);
adminToken= generateToken(adminUser);
});
// ── GET /api/v1/tasks ─────────────────────────────────────────────────
describe('GET /api/v1/tasks', () => {
it('should require authentication', async () => {
await request(app)
.get('/api/v1/tasks')
.expect(401);
});
it('should return empty list when user has no tasks', async () => {
const res = await request(app)
.get('/api/v1/tasks')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
expect(res.body.meta.total).toBe(0);
});
it('should return only the authenticated user\'s tasks', async () => {
// Create tasks for user and adminUser
await Task.create({ title: 'User task', user: user._id });
await Task.create({ title: 'Admin task', user: adminUser._id });
const res = await request(app)
.get('/api/v1/tasks')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].title).toBe('User task');
});
it('should filter by status', async () => {
await Task.create({ title: 'Pending', status: 'pending', user: user._id });
await Task.create({ title: 'Completed', status: 'completed', user: user._id });
const res = await request(app)
.get('/api/v1/tasks?status=pending')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].status).toBe('pending');
});
it('should paginate results', async () => {
// Create 15 tasks
await Task.insertMany(
Array.from({ length: 15 }, (_, i) =>
({ title: `Task ${i + 1}`, user: user._id })
)
);
const res = await request(app)
.get('/api/v1/tasks?page=1&limit=5')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(res.body.data).toHaveLength(5);
expect(res.body.meta.total).toBe(15);
expect(res.body.meta.totalPages).toBe(3);
expect(res.body.meta.hasNextPage).toBe(true);
});
});
// ── POST /api/v1/tasks ────────────────────────────────────────────────
describe('POST /api/v1/tasks', () => {
it('should create a task with valid data', async () => {
const res = await request(app)
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${userToken}`)
.send({ title: 'New Task', priority: 'high' })
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.data.title).toBe('New Task');
expect(res.body.data.priority).toBe('high');
expect(res.body.data.user.toString()).toBe(user._id.toString());
// Verify saved to DB
const saved = await Task.findById(res.body.data._id);
expect(saved).not.toBeNull();
});
it('should return 400 for missing title', async () => {
const res = await request(app)
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${userToken}`)
.send({ priority: 'high' }) // no title
.expect(400);
expect(res.body.message).toBeTruthy();
});
it('should return 400 for invalid priority', async () => {
await request(app)
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${userToken}`)
.send({ title: 'Task', priority: 'critical' }) // invalid enum
.expect(400);
});
});
// ── PATCH /api/v1/tasks/:id ───────────────────────────────────────────
describe('PATCH /api/v1/tasks/:id', () => {
it('should update own task', async () => {
const task = await Task.create({ title: 'Original', user: user._id });
const res = await request(app)
.patch(`/api/v1/tasks/${task._id}`)
.set('Authorization', `Bearer ${userToken}`)
.send({ title: 'Updated', status: 'completed' })
.expect(200);
expect(res.body.data.title).toBe('Updated');
expect(res.body.data.status).toBe('completed');
});
it('should not allow updating another user\'s task', async () => {
const otherTask = await Task.create({ title: 'Other task', user: adminUser._id });
await request(app)
.patch(`/api/v1/tasks/${otherTask._id}`)
.set('Authorization', `Bearer ${userToken}`)
.send({ title: 'Hijacked' })
.expect(404); // 404 not 403 — don't reveal task exists
// Verify not changed in DB
const unchanged = await Task.findById(otherTask._id);
expect(unchanged.title).toBe('Other task');
});
it('should return 400 for invalid ObjectId', async () => {
await request(app)
.patch('/api/v1/tasks/not-a-valid-id')
.set('Authorization', `Bearer ${userToken}`)
.send({ title: 'Test' })
.expect(400);
});
});
// ── DELETE /api/v1/tasks/:id ──────────────────────────────────────────
describe('DELETE /api/v1/tasks/:id', () => {
it('should delete own task', async () => {
const task = await Task.create({ title: 'To delete', user: user._id });
await request(app)
.delete(`/api/v1/tasks/${task._id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(204);
const deleted = await Task.findById(task._id);
expect(deleted).toBeNull();
});
it('should return 404 for already deleted task', async () => {
const task = await Task.create({ title: 'Task', user: user._id });
await task.deleteOne();
await request(app)
.delete(`/api/v1/tasks/${task._id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(404);
});
});
});
How It Works
Step 1 — Supertest Binds to the App Without a Running Server
request(app).get('/path') internally calls app.listen(0) (random port), makes the request, and closes the server. Your Express app does not need to be running — Supertest handles the full HTTP lifecycle within the test. This means tests are self-contained and can run in parallel without port conflicts.
Step 2 — Factory Functions Provide Test-Specific Data
Each test creates its own users and tasks using factory functions in beforeEach. The factory generates unique emails with Date.now() to prevent conflicts between tests even if cleanup is delayed. The token generated from the factory user is valid for the in-memory MongoDB instance — the user record and the JWT both reference the same _id.
Step 3 — Test Both the Response and the Database State
For write operations (POST, PATCH, DELETE), assert both the HTTP response (status code, response body) AND the resulting database state (await Task.findById(id)). This catches bugs where the endpoint returns the right response but fails to persist the change, or persists the wrong value. Read-only endpoints (GET) need only response assertions.
Step 4 — Test Ownership Enforcement Prevents Horizontal Escalation
Every integration test for update/delete operations must include a test where a user tries to modify another user’s resource and verifies it is rejected. This is the most commonly missed security test. The convention of returning 404 (not 403) for another user’s resource prevents information disclosure — the attacker learns nothing about whether the resource exists.
Step 5 — Pagination Tests Verify the Meta Object
Pagination tests must verify both the data array (correct number of items) and the metadata (meta.total, meta.totalPages, meta.hasNextPage). A pagination implementation that returns the wrong total count breaks navigation controls in the frontend. Creating a specific number of records (15 items, page 1 of 3) and asserting exact counts is the reliable way to test this.
Common Mistakes
Mistake 1 — Asserting absolute counts without controlling DB state
❌ Wrong — count depends on how many other tests ran before this one:
it('should return all tasks', async () => {
const res = await request(app).get('/tasks').set(...);
expect(res.body.data).toHaveLength(3); // fails if other tests created tasks!
});
✅ Correct — create specific data in the test and assert on it:
beforeEach(async () => {
await Task.insertMany([{...}, {...}, {...}].map(t => ({ ...t, user: user._id })));
});
it('should return all tasks', async () => {
const res = await request(app).get('/tasks').set(...);
expect(res.body.data).toHaveLength(3); // deterministic — we just created exactly 3
});
Mistake 2 — Not testing the 401 case for protected routes
❌ Wrong — only happy path, no auth enforcement test:
it('returns tasks for authenticated user', async () => {
const res = await request(app).get('/tasks').set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});
// If auth middleware is accidentally removed, this test still passes!
✅ Correct — always test the unauthenticated case first:
it('requires authentication', async () => {
await request(app).get('/tasks').expect(401); // no token
});
Mistake 3 — Not exporting app separately from server.listen()
❌ Wrong — app.listen() in app.js causes port conflicts in tests:
// app.js
const app = express();
// ... routes ...
app.listen(3000); // starts server on import — conflicts with Supertest!
module.exports = app;
✅ Correct — separate app from server start:
// app.js — export only, no listen()
module.exports = express(); // ... routes ...
// server.js — only file that calls listen()
const app = require('./app');
app.listen(process.env.PORT || 3000);
Quick Reference
| Task | Supertest Code |
|---|---|
| GET request | await request(app).get('/path').set('Authorization', token).expect(200) |
| POST with body | await request(app).post('/path').send({ key: 'val' }).expect(201) |
| Assert response body | const res = await req; expect(res.body.data.title).toBe('test') |
| Assert DB state | const doc = await Model.findById(id); expect(doc.field).toBe(value) |
| Test 401 enforcement | await request(app).get('/protected').expect(401) |
| Test 404 for wrong user | User A tries to access User B’s resource → expect 404 |
| Test validation error | await request(app).post('/tasks').send({}).expect(400) |
| In-memory DB setup | MongoMemoryServer.create() in beforeAll |