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
| Benefit | Description |
|---|---|
| Tests real behavior | The actual HTTP layer is exercised, including middleware, routing, validation |
| Catches integration issues | Serialization, content types, status codes, headers |
| Language-agnostic | Tests the interface that any client would use |
| Faster than E2E | No browser needed, but more realistic than unit tests |
| Documents the API | Test 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
| Type | File Name | Content |
|---|---|---|
| Unit tests | userService.test.js | Test functions with mocked dependencies |
| Integration tests | users.routes.test.js | Test HTTP endpoints with Supertest + real DB |
| Setup | setup.js | Database connection, global beforeAll/afterAll |
| Fixtures | fixtures/users.js | Pre-defined test data |
| Helpers | helpers/auth.js | Utility 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
- Supertest lets you test Express routes without starting a real server -- import the app, not the server
- Separate
app.jsfromserver.jsso your app is importable without binding to a port - Test all HTTP methods: GET, POST, PUT, PATCH, DELETE -- each with happy path and error cases
- Assert on everything: status codes, headers (Content-Type), response body shape, and database state
- Test authentication by getting a token in
beforeAlland setting theAuthorizationheader - Test error cases thoroughly: missing fields (400), invalid data (422), not found (404), duplicates (409), unauthorized (401)
- Use
mongodb-memory-serverfor integration tests with a real database -- no external MongoDB needed - Seed and clean data with
beforeEach(insert) andafterEach(delete) for test isolation - Create test helpers for common operations like authentication to keep tests DRY
- 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 >>