Testing Controllers and Services in Node.js
In Node.js application development, especially those following a layered architecture (such as the separation between controllers and services), it's crucial to know how to effectively test each of these layers. Unit tests focus on services to ensure business logic, while integration tests validate the interaction between controllers and services, as well as API responses. Here, mocking plays a vital role in isolating components and controlling the behavior of dependencies.
Example Architecture: Controllers and Services
Before diving into testing, let's look at an example of a common Node.js structure:
// src/services/userService.js
// Simulates interaction with a database or an ORM/ODM model
class UserService {
constructor(userModel) {
this.userModel = userModel; // Model dependency injection
}
async createUser(userData) {
if (!userData.name || !userData.email) {
throw new Error('Name and email are required.');
}
const user = await this.userModel.create(userData);
return user;
}
async getAllUsers() {
const users = await this.userModel.find({});
return users;
}
async getUserById(id) {
const user = await this.userModel.findById(id);
if (!user) {
throw new Error('User not found.');
}
return user;
}
}
module.exports = UserService;
// src/controllers/userController.js
class UserController {
constructor(userService) {
this.userService = userService; // Service dependency injection
}
async createNewUser(req, res) {
try {
const user = await this.userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
if (error.message.includes('required')) {
return res.status(400).json({ message: error.message });
}
res.status(500).json({ message: 'Error creating user', error: error.message });
}
}
async listAllUsers(req, res) {
try {
const users = await this.userService.getAllUsers();
res.status(200).json(users);
} catch (error) {
res.status(500).json({ message: 'Error retrieving users', error: error.message });
}
}
async getUser(req, res) {
try {
const user = await this.userService.getUserById(req.params.id);
res.status(200).json(user);
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
res.status(500).json({ message: 'Error retrieving user', error: error.message });
}
}
}
module.exports = UserController;
// app.js (Basic Express configuration)
const express = require('express');
const app = express();
const UserService = require('./src/services/userService');
const UserController = require('./src/controllers/userController');
// Mongoose model simulation
const UserModelMock = {
create: async (data) => ({ id: 'mockId123', ...data }),
find: async () => [{ id: 'mockId1', name: 'Test User 1', email: 'test1@example.com' }],
findById: async (id) => (id === 'mockId1' ? { id: 'mockId1', name: 'Test User 1', email: 'test1@example.com' } : null),
};
const userService = new UserService(UserModelMock);
const userController = new UserController(userService);
app.use(express.json());
app.post('/api/users', userController.createNewUser.bind(userController));
app.get('/api/users', userController.listAllUsers.bind(userController));
app.get('/api/users/:id', userController.getUser.bind(userController));
module.exports = app; // Export the app for integration tests
Unit Tests for Services
Unit tests for services focus exclusively on the business logic of the service methods, isolating them from external dependencies like the database. To achieve this, we use mocksor stubs to simulate the behavior of the userModel.
Installation (if you don't have Jest yet):
npm install --save-dev jestExample unit test for UserService (with Jest):
// __tests__/services/userService.test.js
const UserService = require('../../src/services/userService');
describe('UserService Unit Tests', () => {
let mockUserModel;
let userService;
// Before each test, reset the model mock
beforeEach(() => {
mockUserModel = {
create: jest.fn(), // Mock the create method
find: jest.fn(), // Mock the find method
findById: jest.fn(), // Mock the findById method
};
userService = new UserService(mockUserModel);
});
describe('createUser', () => {
test('should create a user successfully', async () => {
const userData = { name: 'Jane Doe', email: 'jane@example.com' };
const createdUser = { id: '1', ...userData };
mockUserModel.create.mockResolvedValue(createdUser); // Simulate successful creation
const result = await userService.createUser(userData);
expect(mockUserModel.create).toHaveBeenCalledWith(userData);
expect(result).toEqual(createdUser);
});
test('should throw an error if name is missing', async () => {
const userData = { email: 'jane@example.com' };
await expect(userService.createUser(userData)).rejects.toThrow('Name and email are required.');
expect(mockUserModel.create).not.toHaveBeenCalled(); // Ensure DB was not called
});
test('should throw an error if email is missing', async () => {
const userData = { name: 'Jane Doe' };
await expect(userService.createUser(userData)).rejects.toThrow('Name and email are required.');
expect(mockUserModel.create).not.toHaveBeenCalled();
});
});
describe('getAllUsers', () => {
test('should return all users', async () => {
const usersData = [{ id: '1', name: 'User 1' }, { id: '2', name: 'User 2' }];
mockUserModel.find.mockResolvedValue(usersData);
const result = await userService.getAllUsers();
expect(mockUserModel.find).toHaveBeenCalledWith({});
expect(result).toEqual(usersData);
});
test('should return an empty array if no users exist', async () => {
mockUserModel.find.mockResolvedValue([]);
const result = await userService.getAllUsers();
expect(result).toEqual([]);
});
});
describe('getUserById', () => {
test('should return a user if found', async () => {
const userId = '123';
const userData = { id: userId, name: 'Test User' };
mockUserModel.findById.mockResolvedValue(userData);
const result = await userService.getUserById(userId);
expect(mockUserModel.findById).toHaveBeenCalledWith(userId);
expect(result).toEqual(userData);
});
test('should throw an error if user not found', async () => {
const userId = 'nonexistentId';
mockUserModel.findById.mockResolvedValue(null); // Simulate user not found
await expect(userService.getUserById(userId)).rejects.toThrow('User not found.');
expect(mockUserModel.findById).toHaveBeenCalledWith(userId);
});
});
});
Integration Tests for Controllers (API Endpoints)
Integration tests for controllers verify the complete flow of an HTTP request through the controller layer and to the service (which, in turn, could interact with a real or mocked database depending on the desired scope of the integration). We will use Supertest to simulate HTTP requests to our Express application.
Installation (if you don't have Supertest yet):
npm install --save-dev supertestKey Considerations for Mocking
- Unit Tests for Services: Mock the persistence layer (e.g., the database model). This ensures that only the service's logic is being tested, without relying on the actual database.
- Integration Tests for Controllers: Mock the service layer. This allows you to test how the controller handles HTTP requests and responses, and how it interacts with the service, without involving the detailed internal logic of the service or its dependencies (like the database).
- Jest and `jest.mock()`: Jest makes it easy to mock entire modules. When you mock a module (like `userService` in the controller example), Jest replaces the real module with a mocked version, allowing you to control the behavior of its methods.
Mastering controller and service testing with mocking is fundamental for building robust and maintainable Node.js applications. It allows you to test each layer isolated and with confidence, ensuring that both business logic and API interaction work as expected.