Episode 3 — NodeJS MongoDB Backend Architecture / 3.18 — Testing Tools

3.18.c — API Testing

API testing verifies that your HTTP endpoints behave correctly by sending real requests and asserting on status codes, headers, and response bodies -- Supertest makes this seamless for Express applications without needing a running server.


<< Previous: 3.18.b — Unit Testing with Jest | Next: 3.18.d — Cross-Browser and Web Testing >>


1. What is API Testing?

API testing sits between unit testing and E2E testing on the testing pyramid. Instead of testing individual functions or full browser flows, you test the HTTP interface your application exposes -- the endpoints that clients actually call.

Client (Postman, Frontend, Mobile App)
  │
  │  HTTP Request: POST /api/users { name: "Alice" }
  ▼
┌──────────────────────────────────────┐
│          Express Application          │
│  ┌─────────┐  ┌────────┐  ┌──────┐  │
│  │ Router  │→│ Handler │→│  DB  │  │
│  └─────────┘  └────────┘  └──────┘  │
└──────────────────────────────────────┘
  │
  │  HTTP Response: 201 { data: { id: "123", name: "Alice" } }
  ▼
Client receives response

API TESTING = Sending the HTTP request + Asserting on the HTTP response

Why API Testing Matters

BenefitDescription
Tests real behaviorThe actual HTTP layer is exercised, including middleware, routing, validation
Catches integration issuesSerialization, content types, status codes, headers
Language-agnosticTests the interface that any client would use
Faster than E2ENo browser needed, but more realistic than unit tests
Documents the APITest files serve as examples of how to call each endpoint

2. Supertest: HTTP Assertions for Node.js

Supertest is a library that lets you test Express applications by sending HTTP requests and making assertions on the response -- all without starting a live server.

Installing Supertest

npm install -D supertest

The Key Insight: Export the App, Not the Server

To use Supertest, you must separate your Express app from the server listener.

// app.js — export the Express application
const express = require('express');
const app = express();

app.use(express.json());

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

// DO NOT call app.listen() here
module.exports = app;
// server.js — start the server (only used in production)
const app = require('./app');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
// app.test.js — import the app, pass it to Supertest
const request = require('supertest');
const app = require('./app');

describe('GET /api/health', () => {
  test('returns 200 with status ok', async () => {
    const res = await request(app)
      .get('/api/health')
      .expect(200)
      .expect('Content-Type', /json/);

    expect(res.body).toEqual({ status: 'ok' });
  });
});

Supertest internally creates a temporary server, sends the request, and tears it down. No port conflicts, no server management.


3. Testing GET Endpoints

const request = require('supertest');
const app = require('../app');

describe('GET /api/users', () => {
  test('returns 200 and a list of users', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect(200)
      .expect('Content-Type', /json/);

    expect(res.body.data).toBeInstanceOf(Array);
    expect(res.body.data.length).toBeGreaterThan(0);
  });

  test('supports pagination with query parameters', async () => {
    const res = await request(app)
      .get('/api/users')
      .query({ page: 1, limit: 5 })
      .expect(200);

    expect(res.body.data).toHaveLength(5);
    expect(res.body.pagination).toMatchObject({
      page: 1,
      limit: 5
    });
  });

  test('returns a single user by ID', async () => {
    const res = await request(app)
      .get('/api/users/abc123')
      .expect(200);

    expect(res.body.data).toHaveProperty('name');
    expect(res.body.data).toHaveProperty('email');
    expect(res.body.data._id).toBe('abc123');
  });
});

4. Testing POST Endpoints

describe('POST /api/users', () => {
  test('creates a user and returns 201', async () => {
    const newUser = {
      name: 'Alice',
      email: 'alice@example.com',
      password: 'SecurePass123'
    };

    const res = await request(app)
      .post('/api/users')
      .send(newUser)
      .expect(201)
      .expect('Content-Type', /json/);

    expect(res.body.data).toMatchObject({
      name: 'Alice',
      email: 'alice@example.com'
    });
    expect(res.body.data).toHaveProperty('_id');
    // Password should NOT be in the response
    expect(res.body.data).not.toHaveProperty('password');
  });

  test('returns 400 for missing required fields', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Alice' }) // missing email and password
      .expect(400);

    expect(res.body).toHaveProperty('errors');
    expect(res.body.errors).toBeInstanceOf(Array);
  });

  test('returns 409 for duplicate email', async () => {
    const user = { name: 'Bob', email: 'existing@example.com', password: 'Pass123' };

    // Create the user first
    await request(app).post('/api/users').send(user);

    // Try to create again with same email
    const res = await request(app)
      .post('/api/users')
      .send(user)
      .expect(409);

    expect(res.body.message).toMatch(/already exists/i);
  });
});

5. Testing PUT, PATCH, and DELETE

describe('PUT /api/users/:id', () => {
  test('updates a user completely', async () => {
    const res = await request(app)
      .put('/api/users/abc123')
      .send({ name: 'Alice Updated', email: 'alice.updated@example.com' })
      .expect(200);

    expect(res.body.data.name).toBe('Alice Updated');
  });

  test('returns 404 for non-existent user', async () => {
    await request(app)
      .put('/api/users/nonexistent')
      .send({ name: 'Ghost' })
      .expect(404);
  });
});

describe('PATCH /api/users/:id', () => {
  test('partially updates a user', async () => {
    const res = await request(app)
      .patch('/api/users/abc123')
      .send({ name: 'Alice Patched' })
      .expect(200);

    expect(res.body.data.name).toBe('Alice Patched');
    // Other fields should remain unchanged
    expect(res.body.data).toHaveProperty('email');
  });
});

describe('DELETE /api/users/:id', () => {
  test('deletes a user and returns 200', async () => {
    await request(app)
      .delete('/api/users/abc123')
      .expect(200);

    // Verify the user is actually gone
    await request(app)
      .get('/api/users/abc123')
      .expect(404);
  });

  test('returns 404 when deleting non-existent user', async () => {
    await request(app)
      .delete('/api/users/nonexistent')
      .expect(404);
  });
});

6. Testing Authenticated Routes

describe('Authenticated Routes', () => {
  let authToken;

  beforeAll(async () => {
    // Register and login to get a token
    await request(app).post('/api/auth/register').send({
      name: 'Test User',
      email: 'test@example.com',
      password: 'SecurePass123'
    });

    const loginRes = await request(app).post('/api/auth/login').send({
      email: 'test@example.com',
      password: 'SecurePass123'
    });

    authToken = loginRes.body.token;
  });

  test('accesses protected route with valid token', async () => {
    const res = await request(app)
      .get('/api/users/me')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200);

    expect(res.body.data.email).toBe('test@example.com');
  });

  test('returns 401 without a token', async () => {
    await request(app)
      .get('/api/users/me')
      .expect(401);
  });

  test('returns 401 with an invalid token', async () => {
    await request(app)
      .get('/api/users/me')
      .set('Authorization', 'Bearer invalid-token-here')
      .expect(401);
  });

  test('returns 403 for unauthorized role', async () => {
    // Regular user trying to access admin route
    await request(app)
      .get('/api/admin/dashboard')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(403);
  });
});

7. Testing Error Responses

describe('Error Handling', () => {
  test('returns 404 for unknown routes', async () => {
    const res = await request(app)
      .get('/api/nonexistent-route')
      .expect(404);

    expect(res.body).toHaveProperty('message');
  });

  test('returns 400 for invalid JSON body', async () => {
    await request(app)
      .post('/api/users')
      .set('Content-Type', 'application/json')
      .send('{ invalid json }')
      .expect(400);
  });

  test('returns 400 for invalid MongoDB ObjectId', async () => {
    await request(app)
      .get('/api/users/not-a-valid-id')
      .expect(400);
  });

  test('returns 422 for validation errors with details', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '', email: 'not-an-email', password: '123' })
      .expect(422);

    expect(res.body.errors).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ field: 'name' }),
        expect.objectContaining({ field: 'email' }),
        expect.objectContaining({ field: 'password' })
      ])
    );
  });
});

8. Asserting on Response Body

Supertest provides multiple ways to assert on the response:

// Chained .expect() — checks status, headers
const res = await request(app)
  .get('/api/users')
  .expect(200)
  .expect('Content-Type', /json/)
  .expect('X-Total-Count', '42');

// Callback-style .expect() — custom assertions
await request(app)
  .get('/api/users')
  .expect(200)
  .expect((res) => {
    expect(res.body.data).toHaveLength(3);
    expect(res.body.data[0]).toHaveProperty('name');
    expect(res.body.data[0]).toHaveProperty('email');
    expect(res.body.data[0]).not.toHaveProperty('password');
  });

// Using the response object directly
const res = await request(app).get('/api/users');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body.data).toBeInstanceOf(Array);

9. Integration Tests with a Real Database

For true integration tests, you want to test against a real database, not mocks. mongodb-memory-server spins up an actual MongoDB instance in memory.

Setup

npm install -D mongodb-memory-server

Test Database Configuration

// tests/setup.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');

let mongoServer;

// Start in-memory MongoDB before all tests
beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  const uri = mongoServer.getUri();
  await mongoose.connect(uri);
});

// Clear all collections between tests
afterEach(async () => {
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    await collections[key].deleteMany({});
  }
});

// Stop in-memory MongoDB and close connection after all tests
afterAll(async () => {
  await mongoose.connection.close();
  await mongoServer.stop();
});

Configure Jest to Use the Setup File

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  setupFilesAfterSetup: ['./tests/setup.js'],
  testTimeout: 30000 // in-memory MongoDB can take time to start
};

Seeding Test Data

// tests/fixtures/users.js
const testUsers = [
  {
    name: 'Alice',
    email: 'alice@test.com',
    password: '$2b$10$hashedPassword1', // pre-hashed for speed
    role: 'user'
  },
  {
    name: 'Bob',
    email: 'bob@test.com',
    password: '$2b$10$hashedPassword2',
    role: 'user'
  },
  {
    name: 'Admin',
    email: 'admin@test.com',
    password: '$2b$10$hashedPassword3',
    role: 'admin'
  }
];

module.exports = { testUsers };

10. Complete Test Suite: Full CRUD for a User Resource

// tests/routes/users.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../../app');
const User = require('../../models/User');

describe('User API — /api/users', () => {
  // Seed data before each test
  beforeEach(async () => {
    await User.create([
      { name: 'Alice', email: 'alice@test.com', password: 'hashed1' },
      { name: 'Bob', email: 'bob@test.com', password: 'hashed2' },
      { name: 'Charlie', email: 'charlie@test.com', password: 'hashed3' }
    ]);
  });

  // ------- GET /api/users -------
  describe('GET /api/users', () => {
    test('returns all users with 200 status', async () => {
      const res = await request(app)
        .get('/api/users')
        .expect(200);

      expect(res.body.data).toHaveLength(3);
    });

    test('does not expose password field', async () => {
      const res = await request(app)
        .get('/api/users')
        .expect(200);

      res.body.data.forEach(user => {
        expect(user).not.toHaveProperty('password');
      });
    });

    test('supports search by name query parameter', async () => {
      const res = await request(app)
        .get('/api/users')
        .query({ name: 'Alice' })
        .expect(200);

      expect(res.body.data).toHaveLength(1);
      expect(res.body.data[0].name).toBe('Alice');
    });
  });

  // ------- GET /api/users/:id -------
  describe('GET /api/users/:id', () => {
    test('returns a single user by ID', async () => {
      const user = await User.findOne({ email: 'alice@test.com' });

      const res = await request(app)
        .get(`/api/users/${user._id}`)
        .expect(200);

      expect(res.body.data.name).toBe('Alice');
      expect(res.body.data.email).toBe('alice@test.com');
    });

    test('returns 404 for non-existent ID', async () => {
      const fakeId = new mongoose.Types.ObjectId();
      await request(app)
        .get(`/api/users/${fakeId}`)
        .expect(404);
    });

    test('returns 400 for invalid ObjectId format', async () => {
      await request(app)
        .get('/api/users/invalid-id')
        .expect(400);
    });
  });

  // ------- POST /api/users -------
  describe('POST /api/users', () => {
    test('creates a new user and returns 201', async () => {
      const newUser = {
        name: 'Diana',
        email: 'diana@test.com',
        password: 'Secure123'
      };

      const res = await request(app)
        .post('/api/users')
        .send(newUser)
        .expect(201);

      expect(res.body.data.name).toBe('Diana');
      expect(res.body.data.email).toBe('diana@test.com');
      expect(res.body.data).toHaveProperty('_id');
      expect(res.body.data).not.toHaveProperty('password');

      // Verify actually saved in DB
      const dbUser = await User.findOne({ email: 'diana@test.com' });
      expect(dbUser).not.toBeNull();
    });

    test('returns 400 when name is missing', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ email: 'no-name@test.com', password: 'Pass123' })
        .expect(400);

      expect(res.body).toHaveProperty('errors');
    });

    test('returns 400 when email is invalid', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'Test', email: 'not-an-email', password: 'Pass123' })
        .expect(400);
    });

    test('returns 409 for duplicate email', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'Duplicate', email: 'alice@test.com', password: 'Pass123' })
        .expect(409);
    });
  });

  // ------- PUT /api/users/:id -------
  describe('PUT /api/users/:id', () => {
    test('updates a user and returns 200', async () => {
      const user = await User.findOne({ email: 'alice@test.com' });

      const res = await request(app)
        .put(`/api/users/${user._id}`)
        .send({ name: 'Alice Updated', email: 'alice.updated@test.com' })
        .expect(200);

      expect(res.body.data.name).toBe('Alice Updated');

      // Verify in DB
      const updated = await User.findById(user._id);
      expect(updated.name).toBe('Alice Updated');
    });

    test('returns 404 for non-existent user', async () => {
      const fakeId = new mongoose.Types.ObjectId();
      await request(app)
        .put(`/api/users/${fakeId}`)
        .send({ name: 'Ghost' })
        .expect(404);
    });
  });

  // ------- DELETE /api/users/:id -------
  describe('DELETE /api/users/:id', () => {
    test('deletes a user and returns 200', async () => {
      const user = await User.findOne({ email: 'bob@test.com' });

      await request(app)
        .delete(`/api/users/${user._id}`)
        .expect(200);

      // Verify removed from DB
      const deleted = await User.findById(user._id);
      expect(deleted).toBeNull();
    });

    test('returns 404 for non-existent user', async () => {
      const fakeId = new mongoose.Types.ObjectId();
      await request(app)
        .delete(`/api/users/${fakeId}`)
        .expect(404);
    });

    test('reduces total user count by one', async () => {
      const user = await User.findOne({ email: 'bob@test.com' });
      await request(app).delete(`/api/users/${user._id}`);

      const res = await request(app).get('/api/users').expect(200);
      expect(res.body.data).toHaveLength(2);
    });
  });
});

11. Testing File Uploads

const path = require('path');

describe('POST /api/upload', () => {
  test('uploads a file successfully', async () => {
    const filePath = path.join(__dirname, 'fixtures', 'test-image.jpg');

    const res = await request(app)
      .post('/api/upload')
      .attach('file', filePath)
      .expect(200);

    expect(res.body).toHaveProperty('filename');
    expect(res.body).toHaveProperty('url');
    expect(res.body.mimetype).toBe('image/jpeg');
  });

  test('rejects files that are too large', async () => {
    const largePath = path.join(__dirname, 'fixtures', 'large-file.bin');

    await request(app)
      .post('/api/upload')
      .attach('file', largePath)
      .expect(413);
  });

  test('rejects non-image file types', async () => {
    const textPath = path.join(__dirname, 'fixtures', 'test.txt');

    await request(app)
      .post('/api/upload')
      .attach('file', textPath)
      .expect(400);
  });

  test('uploads with additional form fields', async () => {
    const filePath = path.join(__dirname, 'fixtures', 'test-image.jpg');

    const res = await request(app)
      .post('/api/upload')
      .field('description', 'Profile photo')
      .field('userId', 'user123')
      .attach('file', filePath)
      .expect(200);

    expect(res.body.description).toBe('Profile photo');
  });
});

12. Organizing Test Files

Mirror Source Structure

The clearest approach is to mirror your source directory layout in a tests directory:

project/
  src/
    routes/
      users.js
      posts.js
      auth.js
    middleware/
      authMiddleware.js
      errorHandler.js
    services/
      userService.js
      emailService.js
    models/
      User.js
      Post.js
    app.js
  tests/
    routes/
      users.test.js          ← Integration tests (Supertest)
      posts.test.js
      auth.test.js
    middleware/
      authMiddleware.test.js  ← Unit tests with mocks
      errorHandler.test.js
    services/
      userService.test.js     ← Unit tests with mocked DB
      emailService.test.js
    fixtures/
      users.js                ← Test data
      posts.js
      test-image.jpg          ← Test files for upload tests
    setup.js                  ← Global test setup
  jest.config.js

Naming Conventions

TypeFile NameContent
Unit testsuserService.test.jsTest functions with mocked dependencies
Integration testsusers.routes.test.jsTest HTTP endpoints with Supertest + real DB
Setupsetup.jsDatabase connection, global beforeAll/afterAll
Fixturesfixtures/users.jsPre-defined test data
Helpershelpers/auth.jsUtility functions for tests (e.g., get auth token)

Test Helper Example

// tests/helpers/auth.js
const request = require('supertest');
const app = require('../../app');

async function getAuthToken(email = 'test@test.com', password = 'Pass123') {
  const res = await request(app)
    .post('/api/auth/login')
    .send({ email, password });

  return res.body.token;
}

async function createAndLoginUser(userData) {
  const defaultUser = {
    name: 'Test User',
    email: 'test@test.com',
    password: 'SecurePass123',
    ...userData
  };

  await request(app).post('/api/auth/register').send(defaultUser);
  const token = await getAuthToken(defaultUser.email, defaultUser.password);

  return { user: defaultUser, token };
}

module.exports = { getAuthToken, createAndLoginUser };
// Usage in a test file
const { createAndLoginUser } = require('../helpers/auth');

describe('Protected Routes', () => {
  let token;

  beforeAll(async () => {
    const result = await createAndLoginUser({ role: 'admin' });
    token = result.token;
  });

  test('admin can access admin dashboard', async () => {
    await request(app)
      .get('/api/admin/dashboard')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);
  });
});

Key Takeaways

  1. Supertest lets you test Express routes without starting a real server -- import the app, not the server
  2. Separate app.js from server.js so your app is importable without binding to a port
  3. Test all HTTP methods: GET, POST, PUT, PATCH, DELETE -- each with happy path and error cases
  4. Assert on everything: status codes, headers (Content-Type), response body shape, and database state
  5. Test authentication by getting a token in beforeAll and setting the Authorization header
  6. Test error cases thoroughly: missing fields (400), invalid data (422), not found (404), duplicates (409), unauthorized (401)
  7. Use mongodb-memory-server for integration tests with a real database -- no external MongoDB needed
  8. Seed and clean data with beforeEach (insert) and afterEach (delete) for test isolation
  9. Create test helpers for common operations like authentication to keep tests DRY
  10. Mirror source structure in your tests directory for easy navigation and discoverability

Explain-It Challenge

Scenario: You are building a REST API for a blog platform with these endpoints: POST /api/posts (create post, requires auth), GET /api/posts (list posts, public, supports ?author= and ?tag= filters), GET /api/posts/:id (single post, increments view count), PATCH /api/posts/:id (update post, only the author can edit), DELETE /api/posts/:id (delete post, author or admin can delete), and POST /api/posts/:id/comments (add comment, requires auth).

Write the complete test plan: How many test cases do you need? How do you handle the authorization rules (only author can edit)? How do you test the view counter incrementing correctly under concurrent requests? What fixtures do you need? How do you test the filtering on GET /api/posts? Write at least the describe/it structure with all test case descriptions.


<< Previous: 3.18.b — Unit Testing with Jest | Next: 3.18.d — Cross-Browser and Web Testing >>