Exploring ts-mockito: An Alternative Mocking Library for Node.js Unit Testing
Hleb Bandarenka
Posted on February 8, 2024
Who should read it?
If you've transitioned from the Java/C# realm to NodeJS and find comfort in the reliability of traditional OOP practices, but struggle with writing unit tests and mocking your classes, this article is for you. Jest and Sinon are powerful tools, but may not seamlessly integrate with classes, TypeScript, or Dependency Injection. Curious about alternatives? Enter ts-mockito. 🎉🥳
Prerequisites
- OOP language (Java/C#)
- Unit tests
- Jest/Sinon
Let's rock 🚀🎸
So, like me, you probably prefer a good night's sleep over deploying hotfixes in the wee hours of the morning. And to ensure that luxury, you understand the importance of having robust test coverage.
Naturally, you want to cover all possible scenarios, from the happy paths to the not-so-happy ones, in your code. And you want to do it effortlessly.
However, the reality is that popular libraries like Jest and Sinon aren't exactly tailored for mock classes and TypeScript interfaces. Mocking with them can be a tedious and time-consuming process.
So, I started searching for something like Mockito from the Java world.
What did I need?
- I wanted to easily mock all methods of classes and interfaces.
- It had to work well with TypeScript, so my IDE could help me with auto-complete.
- I also wanted a straightforward verification function for asserts.
And then I discovered - ts-mockito 📦. The name says it all - it's a mix of TypeScript and Mockito.
Sounds exciting, doesn't it? Let's give it a try and see what it can do:
Application 🧩
Imagine we have an application where an employee wants to send a message to their own department.
Following the MVC pattern, we'll have:
- 2 repositories (one for employees and one for departments)
- 2 services (one for departments and one for notifications)
I will show just the main DepartmentService that we will test.
(Github)
export class DepartmentService {
constructor(
private readonly employeeRepository: EmployeeRepository,
private readonly departmentRepository: DepartmentRepository,
private readonly serviceA: NotificationService
) {}
public async sendMessage(input: InputData) {
try {
const employee = await this.employeeRepository.getEmployee(input.employeeId);
const department = await this.departmentRepository.getDepartment(
employee.departmentId
);
await this.serviceA.sendMessage(department, employee, input.message);
} catch (e) {
if (e instanceof Error) {
await this.serviceA.sendAlert(input.employeeId, e.message);
}
}
}
}
export type InputData = {
employeeId: number;
message: string;
};
Now, let's take a look at the same unit tests written using Jest and ts-mockito.
Jest
describe("Department Service", () => {
let employeeRepository: jest.Mocked<EmployeeRepository>;
let departmentRepository: jest.Mocked<DepartmentRepository>;
let notificationService: jest.Mocked<NotificationService>;
let departmentService: DepartmentService;
beforeEach(() => {
employeeRepository = {
getEmployee: jest.fn(),
saveEmployee: jest.fn(),
updateEmployee: jest.fn(),
deleteEmployee: jest.fn(),
} as jest.Mocked<EmployeeRepository>;
departmentRepository = {
getDepartment: jest.fn(),
saveDepartment: jest.fn(),
updateDepartment: jest.fn(),
deleteDepartment: jest.fn(),
} as jest.Mocked<DepartmentRepository>;
notificationService = {
sendMessage: jest.fn(),
sendAlert: jest.fn(),
} as jest.Mocked<NotificationService>;
departmentService = new DepartmentService(
employeeRepository,
departmentRepository,
notificationService
);
});
it("should send a message when employee and department exist", async () => {
// given
const departmentId = 94323;
const employeeId = 2342;
const employee: Employee = {
id: employeeId,
name: "John Doe",
departmentId,
};
const department: Department = { id: departmentId, name: "Department" };
const input = { employeeId: employeeId, message: "Hello" };
employeeRepository.getEmployee.mockResolvedValueOnce(employee);
departmentRepository.getDepartment.mockResolvedValueOnce(department);
// when
await departmentService.sendMessage(input);
// then
expect(employeeRepository.getEmployee).toHaveBeenCalledWith(
input.employeeId
);
expect(departmentRepository.getDepartment).toHaveBeenCalledWith(
departmentId
);
expect(notificationService.sendMessage).toHaveBeenCalledWith(
department,
employee,
input.message
);
expect(notificationService.sendAlert).not.toHaveBeenCalled();
});
it("should send an alert when an error occurs", async () => {
// given
const employeeId = 9834;
const input = { employeeId: employeeId, message: "Hello" };
const errorMessage = "Something went wrong";
const error = new Error(errorMessage);
employeeRepository.getEmployee.mockRejectedValueOnce(error);
// when
await departmentService.sendMessage(input);
// then
expect(notificationService.sendAlert).toHaveBeenCalledWith(
employeeId,
errorMessage
);
expect(notificationService.sendMessage).not.toHaveBeenCalled();
});
});
ts-mockito
import { mock, instance, when, verify, _ } from "@johanblumenberg/ts-mockito";
describe("Department Service", () => {
let employeeRepository: EmployeeRepository;
let departmentRepository: DepartmentRepository;
let notificationService: NotificationService;
let departmentService: DepartmentService;
beforeEach(() => {
employeeRepository = mock(EmployeeRepository);
departmentRepository = mock(DepartmentRepository);
notificationService = mock(NotificationService);
departmentService = new DepartmentService(
instance(employeeRepository),
instance(departmentRepository),
instance(notificationService)
);
});
it("should send a message when employee and department exist", async () => {
// given
const departmentId = 94323;
const employeeId = 2342;
const employee: Employee = {
id: employeeId,
name: "John Doe",
departmentId,
};
const department: Department = { id: departmentId, name: "Department" };
const input = { employeeId: employeeId, message: "Hello" };
when(employeeRepository.getEmployee(employeeId)).thenResolve(employee);
when(departmentRepository.getDepartment(departmentId)).thenResolve(
department
);
// when
await departmentService.sendMessage(input);
// then
verify(employeeRepository.getEmployee(input.employeeId)).called();
verify(departmentRepository.getDepartment(departmentId)).called();
verify(
notificationService.sendMessage(department, employee, input.message)
).called();
verify(notificationService.sendAlert(_, _)).never();
});
it("should send an alert when an error occurs", async () => {
// given
const employeeId = 9834;
const input = { employeeId: employeeId, message: "Hello" };
const errorMessage = "Something went wrong";
const error = new Error(errorMessage);
when(employeeRepository.getEmployee(employeeId)).thenThrow(error);
// when
await departmentService.sendMessage(input);
// then
verify(notificationService.sendAlert(employeeId, errorMessage)).once();
verify(notificationService.sendMessage(_, _, _)).never();
});
});
Comparison results ⚔️⚔️⚔️
- If we count the lines, we'll notice that ts-mockito is 20% shorter.
- Also, I intentionally added more methods in the Repositories to demonstrate that Jest requires us to specify all methods manually and update the mocks every time a new method is added. Just image that you will have to update tests that are not related to a new functionality.
- Additionally, autocomplete doesn't work for parameters in
expect...toHaveBeenCalledWith
with Jest.
Warning ⚠️
You may have noticed that instead of the official ts-mockito library, I imported its fork @johanblumenberg/ts-mockito
. The reason for this is that ts-mockito hasn't received any new commits for the last 3 years. Johan Blumenberg, a developer who made numerous suggestions and pull requests for the library, decided to fork it and fix all the annoying bugs while the official version remains unupdated.
Conclusion
Despite some nuances, my team and I have decided to use forked ts-mockito
for our unit tests. And I believe that this library will make your life as a developer much easier.
P.S. 🤔
You may consider to use @typestrong/ts-mockito as forked alternative to ts-mockito
Posted on February 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 8, 2024