Skip to main content

Express API Testing

When building an Express.js application, ensuring your API endpoints work correctly is crucial. API testing helps you verify that your routes handle requests properly, return expected responses, and manage errors gracefully. In this guide, we'll explore different approaches to testing Express APIs with practical examples.

Why Test Your Express APIs?

Testing your Express APIs provides several benefits:

  • Catch bugs early before they reach production
  • Ensure consistent behavior across API endpoints
  • Document expected API behavior through tests
  • Enable confident refactoring with a safety net
  • Facilitate collaboration by providing a clear specification of how APIs should work

Setting Up Your Testing Environment

Before we start writing tests, let's set up a basic testing environment.

Required Dependencies

bash
npm install --save-dev jest supertest
  • Jest: A JavaScript testing framework
  • Supertest: A library for testing HTTP servers

Configuring Your Project

Add the following to your package.json:

json
{
"scripts": {
"test": "jest --detectOpenHandles"
},
"jest": {
"testEnvironment": "node"
}
}

Basic API Testing with Supertest

Let's start with a simple Express application with a few routes:

javascript
// app.js
const express = require('express');
const app = express();

app.use(express.json());

app.get('/api/users', (req, res) => {
res.status(200).json({
users: [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
]
});
});

app.get('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id);
if (userId === 1) {
return res.status(200).json({ id: 1, name: 'John Doe' });
} else if (userId === 2) {
return res.status(200).json({ id: 2, name: 'Jane Smith' });
}
res.status(404).json({ error: 'User not found' });
});

app.post('/api/users', (req, res) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
// In a real app, we would save to database here
res.status(201).json({ id: 3, name });
});

// Export for testing
module.exports = app;

// Start server only if this file is run directly
if (require.main === module) {
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}

Now, let's write tests for these endpoints:

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

describe('User API', () => {
// Test GET /api/users
test('GET /api/users should return all users', async () => {
const response = await request(app).get('/api/users');

expect(response.status).toBe(200);
expect(response.body).toHaveProperty('users');
expect(response.body.users).toHaveLength(2);
});

// Test GET /api/users/:id
test('GET /api/users/1 should return a specific user', async () => {
const response = await request(app).get('/api/users/1');

expect(response.status).toBe(200);
expect(response.body).toEqual({
id: 1,
name: 'John Doe'
});
});

test('GET /api/users/999 should return 404 for non-existent user', async () => {
const response = await request(app).get('/api/users/999');

expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'User not found');
});

// Test POST /api/users
test('POST /api/users should create a new user', async () => {
const userData = { name: 'Alice Johnson' };
const response = await request(app)
.post('/api/users')
.send(userData)
.set('Content-Type', 'application/json');

expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name', 'Alice Johnson');
});

test('POST /api/users should return 400 when name is missing', async () => {
const userData = {};
const response = await request(app)
.post('/api/users')
.send(userData)
.set('Content-Type', 'application/json');

expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error', 'Name is required');
});
});

Testing with a Mock Database

In real applications, your API routes will likely interact with a database. Here's how to test routes that use a database by mocking the database layer:

javascript
// userModel.js - Mock database model
const users = [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Smith', email: '[email protected]' }
];

module.exports = {
findAll: () => Promise.resolve([...users]),
findById: (id) => Promise.resolve(users.find(user => user.id === id) || null),
create: (user) => {
const newUser = { id: users.length + 1, ...user };
users.push(newUser);
return Promise.resolve(newUser);
}
};
javascript
// userRoutes.js
const express = require('express');
const router = express.Router();
const UserModel = require('./userModel');

router.get('/', async (req, res) => {
try {
const users = await UserModel.findAll();
res.json({ users });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});

router.get('/:id', async (req, res) => {
try {
const userId = parseInt(req.params.id);
const user = await UserModel.findById(userId);

if (!user) {
return res.status(404).json({ error: 'User not found' });
}

res.json(user);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});

router.post('/', async (req, res) => {
try {
const { name, email } = req.body;

if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}

const newUser = await UserModel.create({ name, email });
res.status(201).json(newUser);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});

module.exports = router;
javascript
// app.js (updated)
const express = require('express');
const app = express();
const userRoutes = require('./userRoutes');

app.use(express.json());
app.use('/api/users', userRoutes);

module.exports = app;

Let's test this with mocked database functions:

javascript
// userRoutes.test.js
const request = require('supertest');
const express = require('express');
const userRoutes = require('./userRoutes');
const UserModel = require('./userModel');

// Mock UserModel
jest.mock('./userModel');

// Create test app
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);

describe('User Routes with Mock Database', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
});

test('GET /api/users should return all users', async () => {
const mockUsers = [
{ id: 1, name: 'Test User 1', email: '[email protected]' },
{ id: 2, name: 'Test User 2', email: '[email protected]' }
];

UserModel.findAll.mockResolvedValue(mockUsers);

const response = await request(app).get('/api/users');

expect(response.status).toBe(200);
expect(response.body).toEqual({ users: mockUsers });
expect(UserModel.findAll).toHaveBeenCalledTimes(1);
});

test('GET /api/users/:id should return a specific user', async () => {
const mockUser = { id: 1, name: 'Test User', email: '[email protected]' };

UserModel.findById.mockResolvedValue(mockUser);

const response = await request(app).get('/api/users/1');

expect(response.status).toBe(200);
expect(response.body).toEqual(mockUser);
expect(UserModel.findById).toHaveBeenCalledWith(1);
});

test('POST /api/users should create a new user', async () => {
const newUser = { name: 'New User', email: '[email protected]' };
const createdUser = { id: 3, ...newUser };

UserModel.create.mockResolvedValue(createdUser);

const response = await request(app)
.post('/api/users')
.send(newUser)
.set('Content-Type', 'application/json');

expect(response.status).toBe(201);
expect(response.body).toEqual(createdUser);
expect(UserModel.create).toHaveBeenCalledWith(newUser);
});
});

Testing Authentication Middleware

APIs often include authentication middleware. Here's how to test protected routes:

javascript
// authMiddleware.js
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}

const token = authHeader.split(' ')[1];

// In a real app, you would verify the token
if (token === 'valid-token') {
req.user = { id: 1, name: 'Authenticated User' };
next();
} else {
res.status(401).json({ error: 'Invalid token' });
}
}

module.exports = authenticate;
javascript
// protectedRoute.js
const express = require('express');
const router = express.Router();
const authenticate = require('./authMiddleware');

router.get('/profile', authenticate, (req, res) => {
res.json({
message: 'Protected data',
user: req.user
});
});

module.exports = router;

Now, let's test this protected route:

javascript
// protectedRoute.test.js
const request = require('supertest');
const express = require('express');
const protectedRoutes = require('./protectedRoute');

const app = express();
app.use('/api', protectedRoutes);

describe('Protected Routes', () => {
test('GET /api/profile should return user data with valid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer valid-token');

expect(response.status).toBe(200);
expect(response.body).toHaveProperty('user');
expect(response.body.user).toHaveProperty('id', 1);
expect(response.body.message).toBe('Protected data');
});

test('GET /api/profile should return 401 with invalid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid-token');

expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error', 'Invalid token');
});

test('GET /api/profile should return 401 with no token', async () => {
const response = await request(app).get('/api/profile');

expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error', 'Authentication required');
});
});

Advanced Testing Patterns

Testing File Uploads

Testing file uploads requires a bit more setup:

javascript
// fileUpload.js
const express = require('express');
const multer = require('multer');
const router = express.Router();

const storage = multer.diskStorage({
destination: function(req, file, cb) {
cb(null, './uploads/');
},
filename: function(req, file, cb) {
cb(null, Date.now() + '-' + file.originalname);
}
});

const upload = multer({
storage: storage,
limits: { fileSize: 1024 * 1024 } // 1MB limit
});

router.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}

res.status(201).json({
message: 'File uploaded successfully',
filename: req.file.filename,
size: req.file.size
});
});

module.exports = router;

Testing the file upload route:

javascript
// fileUpload.test.js
const request = require('supertest');
const express = require('express');
const path = require('path');
const fs = require('fs');
const fileUploadRoutes = require('./fileUpload');

const app = express();
app.use('/api', fileUploadRoutes);

// Ensure uploads directory exists
const uploadsDir = './uploads';
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir);
}

describe('File Upload Routes', () => {
// Clean up uploads after tests
afterAll(() => {
const files = fs.readdirSync(uploadsDir);
files.forEach(file => {
fs.unlinkSync(path.join(uploadsDir, file));
});
});

test('POST /api/upload should upload a file successfully', async () => {
const response = await request(app)
.post('/api/upload')
.attach('file', path.join(__dirname, 'testdata/test-image.jpg'));

expect(response.status).toBe(201);
expect(response.body).toHaveProperty('message', 'File uploaded successfully');
expect(response.body).toHaveProperty('filename');
expect(response.body).toHaveProperty('size');
});

test('POST /api/upload should return 400 when no file is provided', async () => {
const response = await request(app)
.post('/api/upload')
.field('description', 'Test without file');

expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error', 'No file uploaded');
});
});

Testing Error Handling

Testing how your API handles errors is crucial for robust applications:

javascript
// errorHandler.js
function errorHandler(err, req, res, next) {
console.error(err.stack);

if (err.type === 'validation') {
return res.status(400).json({ error: err.message });
}

if (err.type === 'not_found') {
return res.status(404).json({ error: err.message });
}

res.status(500).json({
error: 'An unexpected error occurred',
message: process.env.NODE_ENV === 'production' ? null : err.message
});
}

module.exports = errorHandler;
javascript
// errorRoutes.js
const express = require('express');
const router = express.Router();

router.get('/forced-error', (req, res, next) => {
const error = new Error('This is a forced error');
next(error);
});

router.get('/validation-error', (req, res, next) => {
const error = new Error('Validation failed: Missing required fields');
error.type = 'validation';
next(error);
});

router.get('/not-found-error', (req, res, next) => {
const error = new Error('Resource not found');
error.type = 'not_found';
next(error);
});

module.exports = router;

Let's test the error handling:

javascript
// errorHandling.test.js
const request = require('supertest');
const express = require('express');
const errorRoutes = require('./errorRoutes');
const errorHandler = require('./errorHandler');

const app = express();
app.use('/api', errorRoutes);
app.use(errorHandler);

describe('Error Handling', () => {
beforeAll(() => {
// Silence console.error during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterAll(() => {
console.error.mockRestore();
});

test('should handle validation errors with 400 status', async () => {
const response = await request(app).get('/api/validation-error');

expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error', 'Validation failed: Missing required fields');
});

test('should handle not found errors with 404 status', async () => {
const response = await request(app).get('/api/not-found-error');

expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'Resource not found');
});

test('should handle generic errors with 500 status', async () => {
const response = await request(app).get('/api/forced-error');

expect(response.status).toBe(500);
expect(response.body).toHaveProperty('error', 'An unexpected error occurred');
});
});

Testing Strategies

Unit vs. Integration vs. End-to-End Testing

When testing Express APIs, you can apply different strategies:

  1. Unit Tests: Test individual functions or components in isolation

    • Example: Test a validation function or a utility function
  2. Integration Tests: Test how multiple components work together

    • Example: Testing a route that uses middleware, controllers, and models
  3. End-to-End Tests: Test the entire application from the user's perspective

    • Example: Testing an API from client to database and back

Test Coverage

To measure how much of your code is covered by tests, you can use Jest's built-in coverage tool:

json
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
}
}

Run npm run test:coverage to see a detailed report of your test coverage.

Best Practices for API Testing

  1. Isolation: Each test should be independent and not rely on the state of other tests
  2. Testing the happy path and edge cases: Test both normal operations and error conditions
  3. Clear test descriptions: Make test names descriptive so they serve as documentation
  4. Clean up after tests: Reset any state or data modified by tests
  5. Use fixtures and factories: Create reusable test data generators
  6. Test one thing per test: Each test should verify one specific behavior
  7. Mock external dependencies: Use mocks for databases, APIs, or file systems

Summary

Express API testing is a critical part of building reliable and maintainable web applications. In this guide, we've covered:

  • Setting up a testing environment with Jest and Supertest
  • Writing basic API tests for CRUD operations
  • Testing with mocked databases
  • Testing authentication and protected routes
  • Advanced testing patterns including file uploads and error handling
  • Different testing strategies and best practices

By implementing comprehensive API tests, you can ensure your Express application works correctly, handles errors gracefully, and continues to function as expected even when making changes.

Additional Resources

Exercises

  1. Create a simple Express API with at least three endpoints and write comprehensive tests for each.
  2. Implement middleware for rate limiting and write tests to verify its functionality.
  3. Build an API that interacts with a database and write tests using mock database functions.
  4. Create an authentication system with JWT tokens and test the entire flow from login to accessing protected routes.
  5. Implement error handling for your API and write tests for different error scenarios.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)