Testing Express APIs
Testing your Express API endpoints is essential for ensuring your application works correctly. In this lesson, we'll use Supertest, a library specifically designed for testing HTTP APIs with Express.
Setting Up Supertest
Supertest works seamlessly with Jest and allows you to make HTTP requests to your Express app:
npm install --save-dev supertest
First, let's structure our Express app to be testable. Separate app creation from server startup:
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
module.exports = app;
// server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Tip: Separating app creation from server startup allows you to test your app without actually starting a server on a port.
Testing GET Endpoints
Let's create a simple API and test it:
// app.js (continued)
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
app.get('/api/users', (req, res) => {
res.json(users);
});
app.get('/api/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// __tests__/users.test.js
const request = require('supertest');
const app = require('../app');
describe('GET /api/users', () => {
test('should return all users', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(2);
expect(response.body[0]).toHaveProperty('name', 'Alice');
});
});
describe('GET /api/users/:id', () => {
test('should return a specific user', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).toMatchObject({
id: 1,
name: 'Alice',
email: 'alice@example.com'
});
});
test('should return 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/users/999')
.expect(404);
expect(response.body).toHaveProperty('error', 'User not found');
});
});
Testing POST Endpoints
Now let's test creating resources:
// app.js (continued)
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
// Validation
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
if (!email.includes('@')) {
return res.status(400).json({ error: 'Invalid email format' });
}
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
// __tests__/users.test.js (continued)
describe('POST /api/users', () => {
test('should create a new user', async () => {
const newUser = {
name: 'Charlie',
email: 'charlie@example.com'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
test('should reject invalid user data', async () => {
const invalidUser = { name: 'Charlie' }; // Missing email
await request(app)
.post('/api/users')
.send(invalidUser)
.expect(400);
});
test('should reject invalid email format', async () => {
const invalidUser = {
name: 'Charlie',
email: 'not-an-email'
};
const response = await request(app)
.post('/api/users')
.send(invalidUser)
.expect(400);
expect(response.body.error).toContain('Invalid email');
});
});
Testing PUT and DELETE Endpoints
// app.js (continued)
app.put('/api/users/:id', (req, res) => {
const { name, email } = req.body;
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
if (name) users[userIndex].name = name;
if (email) users[userIndex].email = email;
res.json(users[userIndex]);
});
app.delete('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users.splice(userIndex, 1);
res.status(204).send();
});
// __tests__/users.test.js (continued)
describe('PUT /api/users/:id', () => {
test('should update a user', async () => {
const updates = { name: 'Alice Updated' };
const response = await request(app)
.put('/api/users/1')
.send(updates)
.expect(200);
expect(response.body.name).toBe('Alice Updated');
});
test('should return 404 for non-existent user', async () => {
await request(app)
.put('/api/users/999')
.send({ name: 'Test' })
.expect(404);
});
});
describe('DELETE /api/users/:id', () => {
test('should delete a user', async () => {
await request(app)
.delete('/api/users/1')
.expect(204);
});
test('should return 404 for non-existent user', async () => {
await request(app)
.delete('/api/users/999')
.expect(404);
});
});
Testing Middleware
Middleware is a crucial part of Express apps. Let's test authentication middleware:
// middleware/auth.js
function authMiddleware(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
if (token !== 'Bearer valid-token') {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = { id: 1, name: 'Authenticated User' };
next();
}
module.exports = authMiddleware;
// app.js (using middleware)
const authMiddleware = require('./middleware/auth');
app.get('/api/profile', authMiddleware, (req, res) => {
res.json(req.user);
});
// __tests__/auth.test.js
const request = require('supertest');
const app = require('../app');
describe('Authentication Middleware', () => {
test('should reject requests without token', async () => {
await request(app)
.get('/api/profile')
.expect(401);
});
test('should reject requests with invalid token', async () => {
await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
test('should allow requests with valid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body).toHaveProperty('name', 'Authenticated User');
});
});
Warning: Never test with real authentication tokens or credentials. Always use mock data in tests.
Database Testing with Test Fixtures
When testing with a database, use test fixtures and proper setup/teardown:
// __tests__/database.test.js
const db = require('../database');
const request = require('supertest');
const app = require('../app');
describe('User API with Database', () => {
beforeAll(async () => {
await db.connect();
});
beforeEach(async () => {
// Clear database
await db.query('DELETE FROM users');
// Seed test data
await db.query(
'INSERT INTO users (name, email) VALUES (?, ?)',
['Test User', 'test@example.com']
);
});
afterAll(async () => {
await db.disconnect();
});
test('should fetch users from database', async () => {
const response = await request(app)
.get('/api/users')
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].email).toBe('test@example.com');
});
test('should create user in database', async () => {
const newUser = {
name: 'New User',
email: 'new@example.com'
};
await request(app)
.post('/api/users')
.send(newUser)
.expect(201);
const users = await db.query('SELECT * FROM users');
expect(users).toHaveLength(2);
});
});
Note: Use a separate test database or in-memory database (like SQLite :memory:) for testing to avoid affecting production data.
Testing with Environment Variables
// __tests__/setup.js
process.env.NODE_ENV = 'test';
process.env.DB_NAME = 'test_database';
process.env.JWT_SECRET = 'test-secret';
// jest.config.js
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['/__tests__/setup.js'],
coveragePathIgnorePatterns: ['/node_modules/']
};
Practice Exercise:
Create a blog API with the following endpoints:
- GET /api/posts - List all posts
- GET /api/posts/:id - Get single post
- POST /api/posts - Create post (requires auth)
- PUT /api/posts/:id - Update post (requires auth and ownership)
- DELETE /api/posts/:id - Delete post (requires auth and ownership)
Write comprehensive tests for all endpoints, including authentication, authorization, validation, and error cases. Aim for at least 90% code coverage.
Summary
In this lesson, you learned:
- Setting up Supertest for API testing
- Testing GET, POST, PUT, and DELETE endpoints
- Testing middleware and authentication
- Database testing with fixtures
- Setup and teardown for test environments
- Best practices for API testing