Hello Dev Community ๐โโ๏ธ
I've been working with TypeScript for a while, and one thing that always seems to be a time-consuming task is manually creating mocks for unit tests when using dependency injection frameworks.
There is often a lack of consistent structure, which can make the experience even more frustrating. It can get repetitive and result in test suites that are difficult to handle and prone to becoming overly complex.
That's why I created Automock. This project is open-source and seeks to change the way we conduct unit testing in TypeScript DI environments. It automatically creates mock objects for classes, improves testing execution speed, and maintaines a consistent structure within DI frameworks such as NestJS and InversifyJS, as well as popular testing libraries like Jest and Sinon.
๐ Here are some links to check out:
๐ GitHub: https://github.com/automock/automock
๐ Automock's NestJS Official Recipe: https://docs.nestjs.com/recipes/automock
๐ฆ NPM: https://www.npmjs.com/package/@automock/jest
๐ Docs Website: https://automock.dev
The rest of the examples in this post will be based on the following example, which is using NestJS arbitrarily but applies to all DI frameworks:
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserDAL {
async getUsers(): Promise<User[]> { ... }
async getSomethingElse(): Promise<unknown> { ... }
}
@Injectable()
export class UserApiService {
async fetchUsers(): Promise<User[]> { ... }
async fetchSomethingElse(): Promise<unknown> { ... }
}
@Injectable
export class UserService {
constructor(private userDal: UserDAL, private userApi: UserApiService) {}
async getAllUsers(): Promise<User[]> {
const fromDb = await this.userDal.getUsers();
const fromApi = await this.userApi.fetchUsers();
return [...fromDb, ...fromApi];
}
}
The UserService
aggregates user data from both a database and an external api. To test the UserService
in isolation, we need to mock the UserDAL
and UserApiService
classes.
๐ Traditional Approach to Unit Testing
Creating manual mocks for class dependencies is a common part of the unit testing process. This manual method is time-consuming and tedious, especially when working with large classes, here is an example:
const userDalMock: jest.Mocked<UserDAL> = {
getUsers: jest.fn(),
getSomethingElse: jest.fn()
};
const userApiSvcMock: jest.Mocked<UserApiService> = {
fetchUsers: jest.fn(),
fetchSomethingElse: jest.fn()
};
Looks familiar, right? ๐ฅฒ
Regardless of the testing technique you use, these mocks are essential for the class under test. In most cases, you can avoid interacting with other classes in two ways:
Inject these mocks as arguments into newly created instances of the classes.
Leverage your framework's DI container to replace actual objects with their equivalent mock objects.
The primary goal of both approaches is to separate the unit under test (the class) and subject it to predictable and controlled behavior.
Here is the traditional approach using NestJS, which appears to be applicable to all dependency injection frameworks and libraries:
describe('User Service Unit Test', () => {
let userService: UserService; // Under Test
// Mocks Declaration
let userDal: jest.Mocked<UserDAL>;
let apiService: jest.Mocked<ApiService>;
let moduleRef: TestingModule;
beforeAll(async () => {
moduleRef = await Test.createTestingModule({})
.overrideProvider(UserDAL)
.useValue(userDalMock)
.overrideProvider(ApiService)
.useValue(userApiSvcMock)
.compile();
apiService = moduleRef.get(ApiService);
userDal = moduleRef.get(UserDAL);
});
test('should retrieve users', async () => { ... });
});
As we change and refactor the UserService
, it becomes increasingly reliant on additional classes and methods, which can complicate the process of updating these mocks. Furthermore, if there are any modifications to the other classes (UserDAL
and UserApiService
), it will be necessary to also make updates to the stubs.
This continual maintenance requirement can lead to a testing environment that is hard and error-prone since devs need to make sure the mocks accurately reflect the current state of the codebase. This adds another layer of complexity to the development process.
๐ช Automock's Approach
Automatic Mock Generation
Automock's most notable feature is automated mock generation, which eliminates the repetitive task of manually creating and maintaining mock objects.
Here is a typical test suite using Automock:
import { TestBed } from '@automock/jest';
import { UserService, UserDAL, UserApiService } from './services';
describe('Users Service Unit Test', () => {
// ๐งช Unit under test
let userService: UserService;
// ๐ญ Declare Mocks
let mockUserApiService: jest.Mocked<UserApiService>;
let mockUserDal: jest.Mocked<UserDAL>;
beforeAll(() => {
// ๐ Automatically mock every dependency, and create on-the-fly virtual DI container for the mocks
const { unit, unitRef } = TestBed.create(UserService).compile();
// โ๏ธ Assign the unit under test
userService = unit;
// ๐ Retrieve mocks from the unit reference and assign
mockUserApiService = unitRef.get(UserApiService);
mockUserDal = unitRef.get(UserDAL);
});
it('should retrieve users from both database and api', async () => {
// ๐ Arrange
mockUserApiService.fetchUsers.mockResolvedValue([{ id: 1, name: 'Joe' }]);
mockUserDal.getUsers.mockResolvedValue([{ id: 2, name: 'Jane' }]);
// ๐ฌ Act
const users = await userService.getAllUsers();
// โ
Assert
expect(users).toHaveLength(2);
expect(mockUserApiService.fetchUsers).toHaveBeenCalled();
expect(mockUserDal.getUsers).toHaveBeenCalled();
});
});
๐ Full Step-by-Step Example
This ensures consistent structure and syntax within test suites. This consistency is crucial and is maintained even as underlying classes or methods undergo changes or refactoring, providing a stable and reliable testing environment.
Speed
The introduction of a virtual DI container, using the TestBed
, is another key aspect. It speeds up the testing execution by bypassing the conventional DI framework loading, leading to much faster test execution.
The isolation provided in testing ensures that each test runs independently with its own set of automatically provided mock implementations, creating a streamlined and interference-free environment.
๐ Check out the full benchmark:
GitHub: https://github.com/automock/benchmark#visualizations
tsc
demonstrated a significant 28.9% improvement in speed for Automock (vs NestJS). Similarly,tsc-isolated
andSWC
also exhibited notable increases of 26.0% and 23.2% respectively.
* I haven't had the opportunity to test it with other dependency injection frameworks rather then NestJS yet.
๐ง ๐ค The Reason Behind It All
I was motivated to create this project because I faced many common challenges when working with unit testing in different dependency injection frameworks. Regardless of the framework used, the process always proved to be complex and time-consuming. This problem was not limited to my personal experience; it highlighted a wider gap that exists throughout the industry.
I have a clear vision for Automock - to make the testing experience more efficient and improve the reliability and quality of code. This project represents a significant stride towards enhancing development practices within the TypeScript community, making them more efficient and effective.
๐ How Things Stand Right Now, and What Am I Looking for?
Until now, the project has gained around 60K monthly downloads, thanks to the recognition of NestJS, which added Automock to their recipes section.
Currently, the project is in its early-middle stage. It supports NestJS and InversifyJS. I am looking to expand its capabilities and integrate DI frameworks, and I'm seeking contributions from the developer community, especially those proficient in TypeScript, dependency injection, and unit testing methodologies.
It's for You โค๏ธ
Automock is more than just a tool; it is intended to be a community-driven solution aimed at aiding developers facing similar challenges in unit testing.
I invite you to be part of this journey, let's shape the future of software quality and unit testing!
Happy testing! ๐๐งช๐ค
Top comments (3)
Great article!
Thank you for sharing!
Thank you my friend!
The primary goal of both approaches is to separate the unit under test (the class) and subject it to predictable and controlled behavior.