DEV Community

Omer Morad
Omer Morad

Posted on

Unit Test Like a Pro: Automock, My Open Source Answer to Mocking Frustration ๐Ÿค“๐Ÿ’ก๐ŸŽญ

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
};
Enter fullscreen mode Exit fullscreen mode

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 () => { ... });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“š 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 and SWC 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)

Collapse
 
nevodavid profile image
Nevo David

Great article!
Thank you for sharing!

Collapse
 
omermorad profile image
Omer Morad

Thank you my friend!

Collapse
 
orimdominic profile image
Orim Dominic Adah

The primary goal of both approaches is to separate the unit under test (the class) and subject it to predictable and controlled behavior.