Integration tests are the highest-value tests in a MERN application. They exercise the complete request-response cycle: HTTP routing, middleware chain, controller logic, Mongoose operations, and error handling โ all in a single test. Supertest is the Node.js library that makes HTTP requests directly against an Express app without starting a network server, making it fast and perfectly suited for automated testing. Combined with mongodb-memory-server โ an in-memory MongoDB instance that starts in milliseconds โ you get a complete, isolated API test environment with no external dependencies.
Setup โ Supertest and mongodb-memory-server
cd server
npm install --save-dev supertest mongodb-memory-server
Test Database Setup โ Global Configuration
// server/src/__tests__/setup.js โ runs before all test files
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
// Connect to in-memory MongoDB before all tests
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
});
// Disconnect and stop in-memory DB after all tests
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
// Clean all collections between tests โ ensures test isolation
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
// server/package.json โ add setupFilesAfterFramework
// "jest": { "setupFilesAfterFramework": ["./src/__tests__/setup.js"] }
mongodb-memory-server downloads a real MongoDB binary the first time it runs (a few hundred MB). Subsequent runs use the cached binary and start in under a second. This gives you a real MongoDB instance โ all Mongoose features, indexes, and validators work exactly as in production โ without needing MongoDB installed or running separately. The database is in-memory and is destroyed when the test process exits.app from index.js without calling server.listen() โ Supertest starts a temporary HTTP server internally. If your index.js calls server.listen() at the module level (not inside a function), Supertest will start a second server on the same port and crash. Wrap server.listen() in a start() function that is only called when require.main === module (i.e. not when imported by tests).const user = await createTestUser() in each test that needs a user.Test Factory Helpers
// server/src/__tests__/helpers/factories.js
const User = require('../../models/User');
const Post = require('../../models/Post');
const request = require('supertest');
const app = require('../../app'); // export app without listen()
// Create a test user and return the user + auth token
const createTestUser = async (overrides = {}) => {
const defaults = {
name: 'Test User',
email: `test-${Date.now()}@example.com`, // unique email per call
password: 'TestPass@1234',
role: 'user',
};
const user = await User.create({ ...defaults, ...overrides });
// Get a real JWT by logging in via the API
const res = await request(app)
.post('/api/auth/login')
.send({ email: defaults.email, password: defaults.password });
return { user, token: res.body.token };
};
// Create a test post by an authenticated user
const createTestPost = async (token, overrides = {}) => {
const defaults = {
title: 'Test Post Title',
body: 'Test post body content with enough characters.',
published: true,
};
const res = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${token}`)
.send({ ...defaults, ...overrides });
return res.body.data;
};
module.exports = { createTestUser, createTestPost };
Auth Route Tests
// server/src/__tests__/routes/auth.test.js
const request = require('supertest');
const app = require('../../app');
const { createTestUser } = require('../helpers/factories');
describe('POST /api/auth/register', () => {
test('creates a new user and returns a token', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ name: 'Jane', email: 'jane@example.com', password: 'Pass@1234' });
expect(res.statusCode).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.token).toBeDefined();
expect(res.body.data.email).toBe('jane@example.com');
expect(res.body.data).not.toHaveProperty('password'); // no password in response
});
test('returns 409 for duplicate email', async () => {
await request(app).post('/api/auth/register')
.send({ name: 'A', email: 'dup@example.com', password: 'Pass@1234' });
const res = await request(app).post('/api/auth/register')
.send({ name: 'B', email: 'dup@example.com', password: 'Pass@1234' });
expect(res.statusCode).toBe(409);
expect(res.body.success).toBe(false);
});
test('returns 400 for missing required fields', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'incomplete@example.com' }); // missing name and password
expect(res.statusCode).toBe(400);
expect(res.body.errors).toBeDefined(); // field-level errors array
});
});
describe('POST /api/auth/login', () => {
test('returns token for valid credentials', async () => {
const { user } = await createTestUser({ email: 'login@example.com' });
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'login@example.com', password: 'TestPass@1234' });
expect(res.statusCode).toBe(200);
expect(res.body.token).toBeDefined();
});
test('returns 401 for wrong password', async () => {
await createTestUser({ email: 'wrong@example.com' });
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'wrong@example.com', password: 'WrongPassword' });
expect(res.statusCode).toBe(401);
});
});
Post Route Tests โ Auth + CRUD
// server/src/__tests__/routes/posts.test.js
const request = require('supertest');
const app = require('../../app');
const { createTestUser, createTestPost } = require('../helpers/factories');
describe('POST /api/posts', () => {
test('creates a post when authenticated', async () => {
const { token } = await createTestUser();
const res = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${token}`)
.send({ title: 'My Post', body: 'Post body content here', published: true });
expect(res.statusCode).toBe(201);
expect(res.body.data.title).toBe('My Post');
expect(res.body.data.author).toBeDefined();
});
test('returns 401 when no token provided', async () => {
const res = await request(app)
.post('/api/posts')
.send({ title: 'My Post', body: 'Post body' });
expect(res.statusCode).toBe(401);
});
test('returns 400 for missing required fields', async () => {
const { token } = await createTestUser();
const res = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${token}`)
.send({ body: 'No title' }); // missing title
expect(res.statusCode).toBe(400);
});
});
describe('DELETE /api/posts/:id', () => {
test('allows owner to delete their post', async () => {
const { token } = await createTestUser();
const post = await createTestPost(token);
const res = await request(app)
.delete(`/api/posts/${post._id}`)
.set('Authorization', `Bearer ${token}`);
expect(res.statusCode).toBe(200);
});
test('returns 403 when non-owner tries to delete', async () => {
const { token: ownerToken } = await createTestUser();
const { token: otherToken } = await createTestUser();
const post = await createTestPost(ownerToken);
const res = await request(app)
.delete(`/api/posts/${post._id}`)
.set('Authorization', `Bearer ${otherToken}`); // wrong user
expect(res.statusCode).toBe(403);
});
});
Common Mistakes
Mistake 1 โ Calling server.listen() at module level
โ Wrong โ Supertest cannot import the app without starting a server:
// index.js
const server = http.createServer(app);
server.listen(5000); // runs when imported by tests!
module.exports = { app };
โ Correct โ only listen when running as main script:
if (require.main === module) { server.listen(5000); } // โ not in tests
module.exports = { app }; // tests import app without starting server
Mistake 2 โ Testing without auth on protected routes
โ Wrong โ test passes but only because auth is not being enforced in the test:
const res = await request(app).post('/api/posts').send({ title: '...' });
expect(res.statusCode).toBe(201); // passes if auth middleware is broken!
โ Correct โ always test both with and without auth:
// Test 1: returns 401 without token
const res1 = await request(app).post('/api/posts').send(...);
expect(res1.statusCode).toBe(401); // โ auth is enforced
// Test 2: returns 201 with valid token
const res2 = await request(app).post('/api/posts')
.set('Authorization', `Bearer ${token}`).send(...);
expect(res2.statusCode).toBe(201); // โ works when authenticated
Mistake 3 โ Shared test data causing test order dependence
โ Wrong โ test B relies on data created by test A:
let createdPostId; // shared between tests
test('A: creates post', async () => { createdPostId = post._id; });
test('B: deletes post', async () => { await deletePost(createdPostId); }); // fails if A fails
โ Correct โ each test creates its own data using factories.
Quick Reference
| Task | Code |
|---|---|
| GET request | await request(app).get('/api/posts') |
| POST with body | await request(app).post('/api/posts').send({ ... }) |
| Add auth header | .set('Authorization', `Bearer ${token}`) |
| Assert status | expect(res.statusCode).toBe(201) |
| Assert body field | expect(res.body.data.title).toBe('...') |
| Assert field absent | expect(res.body.data).not.toHaveProperty('password') |