Integration Testing the Express API with Supertest

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"] }
Note: 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.
Tip: Export the Express 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).
Warning: Integration tests that depend on a specific order of operations are fragile. Each test should be fully independent โ€” create all the data it needs, make assertions, and the afterEach cleanup handles teardown. Never rely on data created by a previous test. Use factory functions or helper utilities to create test data consistently: 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')

🧠 Test Yourself

Your integration test for DELETE /api/posts/:id returns 200 even when you do not send an Authorization header. You check the route โ€” router.delete('/:id', protect, deletePost) โ€” and protect middleware is there. What is the most likely explanation?