Express Test Organization
Testing is a critical part of developing reliable Express applications. Well-organized tests not only help catch bugs early but also serve as documentation for how your API should behave. In this guide, we'll explore strategies for organizing your Express tests to create maintainable, reliable test suites.
Introduction to Test Organization
When working with Express.js applications, tests can quickly become numerous and complex. Good test organization helps you:
- Find and run specific tests easily
- Understand the test's purpose at a glance
- Maintain tests as your application evolves
- Share testing patterns across your team
This guide will show you how to structure your Express tests using popular testing frameworks while following industry best practices.
Choosing a Test Directory Structure
The first step in organizing Express tests is establishing a clear directory structure. Here's a recommended approach:
project-root/
├── src/
│ ├── controllers/
│ ├── models/
│ ├── routes/
│ └── middleware/
├── tests/
│ ├── unit/
│ │ ├── controllers/
│ │ ├── models/
│ │ └── middleware/
│ ├── integration/
│ │ └── routes/
│ ├── e2e/
│ └── fixtures/
└── package.json
This structure separates tests into three main categories:
- Unit tests: Test individual functions and methods in isolation
- Integration tests: Test how components work together (like routes with controllers)
- E2E (End-to-End) tests: Test the entire application flow from start to finish
The fixtures
directory stores test data, mock objects, and other resources used across tests.
Setting Up Test Configuration
Before writing tests, it's helpful to establish shared configuration. Using Jest as an example:
// jest.config.js
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.js',
'!src/server.js'
],
setupFilesAfterEnv: ['./tests/setup.js']
};
Create a setup file for shared test setup code:
// tests/setup.js
process.env.NODE_ENV = 'test';
// Add global test setup here
Organizing Different Test Types
Unit Tests
Unit tests should be small, fast, and focused on testing a single unit of code in isolation.
// tests/unit/controllers/user.controller.test.js
const userController = require('../../../src/controllers/user.controller');
const User = require('../../../src/models/user.model');
// Mock dependencies
jest.mock('../../../src/models/user.model');
describe('User Controller', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
});
describe('getUser', () => {
it('should return user when valid ID is provided', async () => {
// Arrange
const mockUser = { id: '123', name: 'Test User' };
const mockReq = { params: { id: '123' } };
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
User.findById.mockResolvedValue(mockUser);
// Act
await userController.getUser(mockReq, mockRes);
// Assert
expect(User.findById).toHaveBeenCalledWith('123');
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(mockUser);
});
it('should return 404 when user is not found', async () => {
// Arrange, Act, Assert
// ...
});
});
});
Integration Tests
Integration tests verify that different parts of your application work together correctly.
// tests/integration/routes/user.routes.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../../../src/app');
const User = require('../../../src/models/user.model');
describe('User Routes', () => {
beforeAll(async () => {
// Connect to test database
await mongoose.connect(process.env.TEST_MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
});
afterAll(async () => {
await mongoose.connection.close();
});
beforeEach(async () => {
// Clear test database before each test
await User.deleteMany({});
});
describe('GET /api/users/:id', () => {
it('should return a user when valid ID is provided', async () => {
// Create a test user
const user = await User.create({
name: 'Test User',
email: '[email protected]',
password: 'password123'
});
// Make request
const response = await request(app)
.get(`/api/users/${user._id}`)
.expect(200);
// Assertions
expect(response.body).toHaveProperty('name', 'Test User');
expect(response.body).toHaveProperty('email', '[email protected]');
expect(response.body).not.toHaveProperty('password');
});
it('should return 404 for non-existent user', async () => {
const nonExistentId = new mongoose.Types.ObjectId();
await request(app)
.get(`/api/users/${nonExistentId}`)
.expect(404);
});
});
});
End-to-End Tests
E2E tests simulate real user scenarios by testing the application from start to finish.
// tests/e2e/auth.test.js
const request = require('supertest');
const app = require('../../src/app');
const User = require('../../src/models/user.model');
describe('Authentication Flow', () => {
beforeAll(async () => {
// Setup database connection
});
afterAll(async () => {
// Close database connection
});
beforeEach(async () => {
// Clear relevant database collections
await User.deleteMany({});
});
it('should register, login, and access protected resource', async () => {
// Register a new user
const registerResponse = await request(app)
.post('/api/auth/register')
.send({
name: 'Test User',
email: '[email protected]',
password: 'securePassword123'
})
.expect(201);
// Login with the registered user
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'securePassword123'
})
.expect(200);
const token = loginResponse.body.token;
// Access protected resource with token
const profileResponse = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(profileResponse.body).toHaveProperty('name', 'Test User');
});
});
Using Test Helpers and Fixtures
Create reusable helpers and fixtures to reduce duplication in your tests:
// tests/fixtures/users.js
const mongoose = require('mongoose');
const testUsers = [
{
_id: new mongoose.Types.ObjectId(),
name: 'Admin User',
email: '[email protected]',
password: 'hashedPassword1',
role: 'admin'
},
{
_id: new mongoose.Types.ObjectId(),
name: 'Regular User',
email: '[email protected]',
password: 'hashedPassword2',
role: 'user'
}
];
module.exports = testUsers;
// tests/helpers/auth.helper.js
const jwt = require('jsonwebtoken');
const config = require('../../src/config');
const generateTestToken = (userId, role = 'user') => {
return jwt.sign({ id: userId, role }, config.jwtSecret, {
expiresIn: '1h'
});
};
module.exports = {
generateTestToken
};
Setting Up Test Environments
Different test environments may require different configurations. Use environment variables to manage this:
// tests/setup.js
switch(process.env.TEST_ENV) {
case 'e2e':
process.env.MONGODB_URI = process.env.TEST_E2E_MONGODB_URI;
process.env.PORT = 3001;
break;
case 'integration':
process.env.MONGODB_URI = process.env.TEST_INTEGRATION_MONGODB_URI;
break;
default:
// Unit tests typically use mocks instead of real databases
break;
}
Testing Express Middleware
Middleware functions are critical in Express apps and require proper testing:
// tests/unit/middleware/auth.middleware.test.js
const authMiddleware = require('../../../src/middleware/auth.middleware');
const jwt = require('jsonwebtoken');
const config = require('../../../src/config');
jest.mock('jsonwebtoken');
describe('Auth Middleware', () => {
let mockReq;
let mockRes;
let nextFunction;
beforeEach(() => {
mockReq = {
headers: {}
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
nextFunction = jest.fn();
});
it('should call next() when token is valid', () => {
// Arrange
const user = { id: '123', role: 'user' };
mockReq.headers.authorization = 'Bearer valid-token';
jwt.verify.mockImplementation((token, secret, callback) => {
callback(null, user);
});
// Act
authMiddleware(mockReq, mockRes, nextFunction);
// Assert
expect(jwt.verify).toHaveBeenCalledWith(
'valid-token',
config.jwtSecret,
expect.any(Function)
);
expect(mockReq.user).toEqual(user);
expect(nextFunction).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 401 when no token is provided', () => {
// Act
authMiddleware(mockReq, mockRes, nextFunction);
// Assert
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.any(String) })
);
expect(nextFunction).not.toHaveBeenCalled();
});
});
Real-World Example: Testing an Express API
Let's see how all these concepts come together by testing a user management API:
The Express Route and Controller
// src/controllers/user.controller.js
const User = require('../models/user.model');
exports.createUser = async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'User with this email already exists' });
}
// Create new user
const user = new User({ name, email, password });
await user.save();
// Return new user (excluding password)
const userResponse = user.toObject();
delete userResponse.password;
return res.status(201).json(userResponse);
} catch (error) {
return res.status(500).json({ message: 'Server error', error: error.message });
}
};
The Test Suite
// tests/integration/routes/user.routes.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../../../src/app');
const User = require('../../../src/models/user.model');
const { generateTestToken } = require('../../helpers/auth.helper');
describe('User API Routes', () => {
let adminToken;
beforeAll(async () => {
await mongoose.connect(process.env.TEST_MONGO_URI);
// Create admin user and generate token
const admin = await User.create({
name: 'Admin User',
email: '[email protected]',
password: 'password123',
role: 'admin'
});
adminToken = generateTestToken(admin._id, 'admin');
});
afterAll(async () => {
await mongoose.connection.close();
});
beforeEach(async () => {
// Clean up users collection before each test
await User.deleteMany({ email: { $ne: '[email protected]' } });
});
describe('POST /api/users', () => {
it('should create a new user when valid data is provided', async () => {
const newUser = {
name: 'New Test User',
email: '[email protected]',
password: 'password123'
};
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken}`)
.send(newUser)
.expect(201);
// Check response
expect(response.body).toHaveProperty('_id');
expect(response.body.name).toBe(newUser.name);
expect(response.body.email).toBe(newUser.email);
expect(response.body).not.toHaveProperty('password');
// Verify user was saved to database
const savedUser = await User.findOne({ email: newUser.email });
expect(savedUser).not.toBeNull();
});
it('should return 400 when email already exists', async () => {
// Create a user first
await User.create({
name: 'Existing User',
email: '[email protected]',
password: 'password123'
});
// Try to create another user with the same email
const duplicateUser = {
name: 'Another User',
email: '[email protected]',
password: 'anotherpassword'
};
await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken}`)
.send(duplicateUser)
.expect(400);
});
it('should return 401 when no auth token is provided', async () => {
const newUser = {
name: 'Unauthorized User',
email: '[email protected]',
password: 'password123'
};
await request(app)
.post('/api/users')
.send(newUser)
.expect(401);
// Verify user was not created
const user = await User.findOne({ email: '[email protected]' });
expect(user).toBeNull();
});
});
});
Best Practices for Express Test Organization
-
Isolate tests: Each test should run independently without relying on state from other tests.
-
Use descriptive names: Name your test files and test cases clearly to indicate what they test.
-
Follow the AAA pattern: Arrange (set up test data), Act (call the method being tested), Assert (verify results).
-
Mock external dependencies: Use mocks for databases, APIs, and other external services in unit tests.
-
Test both success and error paths: Ensure your tests cover both successful operations and error handling.
-
Use test hooks appropriately:
beforeAll
/afterAll
for setup/teardown that applies to all testsbeforeEach
/afterEach
for setup/teardown that applies to each test
-
Keep tests DRY: Use helpers, fixtures, and shared setup to avoid repetition.
-
Use separate databases for testing: Never run tests against production databases.
Running Tests Efficiently
Organize your package.json
scripts to run different types of tests:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:unit": "jest tests/unit",
"test:integration": "TEST_ENV=integration jest tests/integration",
"test:e2e": "TEST_ENV=e2e jest tests/e2e",
"test:coverage": "jest --coverage"
}
}
Summary
Effective test organization is crucial for maintaining a healthy Express.js application. By separating tests into unit, integration, and E2E categories, using proper directory structures, and following best practices, you can create a test suite that provides confidence in your code and makes maintenance easier.
Remember that good test organization is not just about folder structure—it's about creating a testing strategy that works for your team and application. As your Express application grows, your testing approach may need to evolve as well.
Additional Resources
- Jest Documentation
- Supertest Documentation
- Mocha Documentation
- Chai Assertion Library
- Express.js Testing Best Practices
Exercises
-
Set up a basic Express application with a user management API and create a complete test structure for it.
-
Write unit tests for a middleware that validates request parameters.
-
Create integration tests for a route that requires authentication.
-
Write an E2E test that simulates a user registration, login, and profile update flow.
-
Refactor an existing test suite to use shared fixtures and helpers.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)