Express Supertest
Introduction
When building Express applications, testing your API endpoints is crucial to ensure your application behaves as expected. Supertest is a powerful Node.js library that allows you to test HTTP servers by making requests to your Express application and asserting the responses.
Supertest provides an elegant way to write integration tests for your Express routes without having to manually start your server or make actual network requests. It works by programmatically sending HTTP requests to your Express app and letting you assert on the responses.
In this guide, you'll learn how to use Supertest to write effective tests for your Express applications.
Getting Started with Supertest
Installation
First, you need to install Supertest along with a testing framework. For this guide, we'll use Jest, but you can use Mocha, Jasmine, or any other testing framework of your choice.
npm install --save-dev supertest jest
Let's also set up a basic Express application to test:
npm install express
Basic Setup
Here's a simple Express application that we'll be testing:
// app.js
const express = require('express');
const app = express();
app.use(express.json());
// A simple route that returns a welcome message
app.get('/api', (req, res) => {
res.status(200).json({ message: 'Welcome to the API' });
});
// A route that returns user data
app.get('/api/users/:id', (req, res) => {
const id = req.params.id;
// Mock user data for demo purposes
const userData = {
id,
name: 'John Doe',
email: '[email protected]'
};
res.status(200).json(userData);
});
// A route that accepts POST requests to create users
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
// In a real app, this is where you'd save to a database
const newUser = {
id: Date.now().toString(),
name,
email
};
res.status(201).json(newUser);
});
// Don't do app.listen() here, as Supertest will handle that
module.exports = app; // Export for testing
Writing Your First Supertest Test
Now let's write our first test for the Express application. Create a new file called app.test.js
:
// app.test.js
const request = require('supertest');
const app = require('./app');
describe('API Endpoints', () => {
test('GET /api should return welcome message', async () => {
const response = await request(app)
.get('/api')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.message).toBe('Welcome to the API');
});
});
Let's break down what's happening:
- We import
supertest
and our Express app - We use
request(app)
to create a test agent that wraps around our Express app - We make a GET request to
/api
- We assert that the Content-Type header contains "json"
- We assert that the status code is 200
- We check that the response body has the expected message
To run this test with Jest, add a script to your package.json
:
{
"scripts": {
"test": "jest --detectOpenHandles"
}
}
Then run:
npm test
Testing Different HTTP Methods
Supertest supports all HTTP methods. Let's add more tests for our other routes:
test('GET /api/users/:id should return user data', async () => {
const userId = '123';
const response = await request(app)
.get(`/api/users/${userId}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toEqual({
id: userId,
name: 'John Doe',
email: '[email protected]'
});
});
test('POST /api/users should create a new user', async () => {
const userData = {
name: 'Jane Smith',
email: '[email protected]'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body.name).toBe(userData.name);
expect(response.body.email).toBe(userData.email);
expect(response.body).toHaveProperty('id');
});
test('POST /api/users with missing data should return 400', async () => {
const incompleteUserData = {
name: 'Incomplete User'
// Missing email
};
const response = await request(app)
.post('/api/users')
.send(incompleteUserData)
.expect('Content-Type', /json/)
.expect(400);
expect(response.body.error).toBe('Name and email are required');
});
Testing with Query Parameters
You can also test endpoints that use query parameters:
// First, add this route to your app.js
app.get('/api/search', (req, res) => {
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: 'Query parameter is required' });
}
// Mock search results
const results = [
{ id: 1, name: `Result for ${query}` },
{ id: 2, name: `Another result for ${query}` }
];
res.status(200).json(results);
});
// Then add this test to app.test.js
test('GET /api/search with query param should return search results', async () => {
const searchQuery = 'javascript';
const response = await request(app)
.get('/api/search')
.query({ query: searchQuery })
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].name).toContain(searchQuery);
});
Testing Authentication and Headers
Many APIs require authentication headers. Here's how to test them:
// First, add this route to your app.js
app.get('/api/protected', (req, res) => {
const token = req.headers.authorization;
if (!token || token !== 'Bearer valid-token') {
return res.status(401).json({ error: 'Unauthorized' });
}
res.status(200).json({ data: 'Protected resource' });
});
// Then add these tests to app.test.js
test('GET /api/protected with valid token should return data', async () => {
const response = await request(app)
.get('/api/protected')
.set('Authorization', 'Bearer valid-token')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.data).toBe('Protected resource');
});
test('GET /api/protected with invalid token should return 401', async () => {
const response = await request(app)
.get('/api/protected')
.set('Authorization', 'Bearer invalid-token')
.expect('Content-Type', /json/)
.expect(401);
expect(response.body.error).toBe('Unauthorized');
});
Testing File Uploads
Supertest can also test file upload endpoints:
// First, add multer to handle file uploads and add this route to app.js
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });
app.post('/api/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.status(200).json({
message: 'File uploaded successfully',
filename: req.file.filename,
mimetype: req.file.mimetype
});
});
// Then add this test to app.test.js
test('POST /api/upload should handle file uploads', async () => {
const response = await request(app)
.post('/api/upload')
.attach('file', Buffer.from('test file content'), {
filename: 'test.txt',
contentType: 'text/plain'
})
.expect(200);
expect(response.body.message).toBe('File uploaded successfully');
expect(response.body.filename).toBeDefined();
expect(response.body.mimetype).toBe('text/plain');
});
Advanced Techniques
Testing with Cookies
You can test endpoints that set or require cookies:
// Add this route to app.js
app.get('/api/set-cookie', (req, res) => {
res.cookie('session', 'test-session-id', {
httpOnly: true,
maxAge: 3600000
});
res.status(200).json({ message: 'Cookie set' });
});
app.get('/api/check-cookie', (req, res) => {
const sessionCookie = req.cookies.session;
if (!sessionCookie) {
return res.status(401).json({ error: 'No session cookie' });
}
res.status(200).json({ message: 'Valid session' });
});
// Add these tests to app.test.js
const agent = request.agent(app);
test('Cookie session flow', async () => {
// First request to get the cookie
await agent
.get('/api/set-cookie')
.expect(200)
.expect('Set-Cookie', /session/);
// Second request should include the cookie automatically
const response = await agent
.get('/api/check-cookie')
.expect(200);
expect(response.body.message).toBe('Valid session');
});
Testing Error Handling
Testing how your app handles errors is just as important:
// Add this route to app.js
app.get('/api/error', (req, res, next) => {
const error = new Error('Simulated error');
error.status = 500;
next(error);
});
// Add error handler middleware
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
error: {
message: err.message || 'Internal Server Error'
}
});
});
// Add this test to app.test.js
test('GET /api/error should handle errors correctly', async () => {
const response = await request(app)
.get('/api/error')
.expect('Content-Type', /json/)
.expect(500);
expect(response.body.error.message).toBe('Simulated error');
});
Real-World Example: Testing a Todo API
Let's build a more complete example with a Todo API:
// todo.js
const express = require('express');
const router = express.Router();
let todos = [];
// Get all todos
router.get('/', (req, res) => {
res.json(todos);
});
// Get a single todo
router.get('/:id', (req, res) => {
const todo = todos.find(t => t.id === req.params.id);
if (!todo) {
return res.status(404).json({ error: 'Todo not found' });
}
res.json(todo);
});
// Create a todo
router.post('/', (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const newTodo = {
id: Date.now().toString(),
title,
completed: false,
createdAt: new Date()
};
todos.push(newTodo);
res.status(201).json(newTodo);
});
// Update a todo
router.put('/:id', (req, res) => {
const { title, completed } = req.body;
const todoIndex = todos.findIndex(t => t.id === req.params.id);
if (todoIndex === -1) {
return res.status(404).json({ error: 'Todo not found' });
}
todos[todoIndex] = {
...todos[todoIndex],
title: title !== undefined ? title : todos[todoIndex].title,
completed: completed !== undefined ? completed : todos[todoIndex].completed
};
res.json(todos[todoIndex]);
});
// Delete a todo
router.delete('/:id', (req, res) => {
const todoIndex = todos.findIndex(t => t.id === req.params.id);
if (todoIndex === -1) {
return res.status(404).json({ error: 'Todo not found' });
}
const deletedTodo = todos[todoIndex];
todos.splice(todoIndex, 1);
res.json(deletedTodo);
});
// For testing purposes, we need a way to reset todos
router.delete('/', (req, res) => {
todos = [];
res.status(204).end();
});
module.exports = router;
Now update your app.js
to include the todo routes:
// app.js
const express = require('express');
const app = express();
const todoRouter = require('./todo');
app.use(express.json());
app.use('/api/todos', todoRouter);
module.exports = app;
Finally, let's write comprehensive tests for our Todo API:
// todo.test.js
const request = require('supertest');
const app = require('./app');
describe('Todo API', () => {
beforeEach(async () => {
// Reset todos before each test
await request(app).delete('/api/todos');
});
describe('GET /api/todos', () => {
test('should return empty array initially', async () => {
const response = await request(app)
.get('/api/todos')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toEqual([]);
});
test('should return todos after adding some', async () => {
// First add a todo
const todo = { title: 'Test todo' };
await request(app).post('/api/todos').send(todo);
// Then check if GET returns it
const response = await request(app)
.get('/api/todos')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].title).toBe(todo.title);
});
});
describe('POST /api/todos', () => {
test('should create a new todo with valid data', async () => {
const todo = { title: 'Buy groceries' };
const response = await request(app)
.post('/api/todos')
.send(todo)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body.title).toBe(todo.title);
expect(response.body.completed).toBe(false);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('createdAt');
});
test('should return 400 if title is missing', async () => {
const response = await request(app)
.post('/api/todos')
.send({})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body.error).toBe('Title is required');
});
});
describe('GET /api/todos/:id', () => {
test('should return a specific todo', async () => {
// First create a todo
const createResponse = await request(app)
.post('/api/todos')
.send({ title: 'Find todo by ID test' });
const todoId = createResponse.body.id;
// Then retrieve it by ID
const response = await request(app)
.get(`/api/todos/${todoId}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.id).toBe(todoId);
expect(response.body.title).toBe('Find todo by ID test');
});
test('should return 404 for non-existent todo', async () => {
const response = await request(app)
.get('/api/todos/non-existent-id')
.expect('Content-Type', /json/)
.expect(404);
expect(response.body.error).toBe('Todo not found');
});
});
describe('PUT /api/todos/:id', () => {
test('should update an existing todo', async () => {
// First create a todo
const createResponse = await request(app)
.post('/api/todos')
.send({ title: 'Original title' });
const todoId = createResponse.body.id;
// Then update it
const response = await request(app)
.put(`/api/todos/${todoId}`)
.send({
title: 'Updated title',
completed: true
})
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.id).toBe(todoId);
expect(response.body.title).toBe('Updated title');
expect(response.body.completed).toBe(true);
});
});
describe('DELETE /api/todos/:id', () => {
test('should delete an existing todo', async () => {
// First create a todo
const createResponse = await request(app)
.post('/api/todos')
.send({ title: 'Todo to delete' });
const todoId = createResponse.body.id;
// Then delete it
const deleteResponse = await request(app)
.delete(`/api/todos/${todoId}`)
.expect('Content-Type', /json/)
.expect(200);
expect(deleteResponse.body.id).toBe(todoId);
// Verify it's gone
await request(app)
.get(`/api/todos/${todoId}`)
.expect(404);
});
});
});
Best Practices for Express Supertest
-
Reset state between tests: As shown in the Todo API example, reset any state (like database records) between tests to avoid dependencies between tests.
-
Test both success and error cases: Always test how your application handles errors, not just successful operations.
-
Use descriptive test names: Your test names should clearly describe what they're testing.
-
Group related tests: Use
describe
blocks to group related tests for better organization. -
Test all API endpoints: Ensure complete coverage by testing all endpoints and HTTP methods.
-
Test validation logic: Ensure your application validates input correctly.
-
Use before/after hooks: Set up and tear down any necessary state with
beforeEach
,afterEach
,beforeAll
, andafterAll
. -
Mock external dependencies: If your Express app depends on external services, mock them to avoid making real network requests during testing.
Summary
In this guide, you've learned:
- How to set up Supertest with Express
- Writing basic HTTP tests for various endpoints
- Testing different HTTP methods (GET, POST, PUT, DELETE)
- Working with query parameters, headers, and cookies
- Testing file uploads and error handling
- Building comprehensive tests for a real-world Todo API
- Best practices for testing Express applications with Supertest
Supertest provides an elegant, fluent API for testing Express applications without having to manually spin up servers. By incorporating it into your development workflow, you can ensure that your API endpoints behave correctly and catch bugs before they reach production.
Additional Resources
- Supertest GitHub Repository
- Jest Documentation
- Express.js Documentation
- REST API Testing Best Practices
Exercises
-
Extend the Todo API example to include filtering todos by completion status (e.g.,
/api/todos?completed=true
). -
Add authentication middleware to the Todo API and update the tests to include authentication headers.
-
Add pagination to the Todo API (e.g.,
/api/todos?page=1&limit=10
) and write tests for it. -
Create a new Express route that accepts file uploads with metadata and write tests for it.
-
Implement rate limiting middleware and write tests to verify it works correctly.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)