Jest unit test best practices and recommendations

Jest unit test best practices and recommendations

Table of contents

No heading

No headings in the article.

Introduction: Unit testing is a crucial aspect of software development that ensures the reliability and correctness of individual components or units of code. We will discuss the unit tests implemented for the UserService class in a TypeScript project. We will analyze the code snippets, explain the purpose of each unit test, and highlight best practices followed during the testing process.

Code Overview: The provided code focuses on unit testing the UserService class, which is responsible for user-related operations such as user registration (SignUpUser) and user login (LoginUser). The UserService class depends on other components such as the UserRepository, which interacts with the database, and various utility functions.

link to the full project code

Unit Test Structure: The unit tests are organized using the Jest testing framework, which is a popular choice for JavaScript and TypeScript projects. The describe() function is used to group related tests, and the it() function is used to define individual test cases within each describe block.

Test Mocking: To isolate the UserService class during testing, the UserRepository and utility functions are mocked using the jest.mock() function. Mocking allows us to replace the actual dependencies with custom implementations that we can control and inspect during testing. In this case, the UserRepository is mocked using jest.Mocked, which provides type safety and allows us to specify the desired behavior of the mocked functions.

Test Setup: Before each test case, a setup function (createMockUserRepository) is invoked to create a mock instance of the UserRepository. The mock repository is then assigned to the UserService instance being tested. This setup ensures that each test case starts with a clean and consistent state.


let userService: UserService;
let userRepositoryMock: jest.Mocked<UserRepository>;

const createMockUserRepository = () => {
  const userRepository = new UserRepository() as jest.Mocked<UserRepository>;
  userRepository.CreateUser.mockResolvedValue({ _id: "123" });
  userRepository.FindUserByEmail.mockResolvedValue({ _id: "123" });
  return userRepository;
};

beforeEach(() => {
  userRepositoryMock = createMockUserRepository();
  userService = new UserService();
  userService["repository"] = userRepositoryMock;
});

Test Cleanup: After each test case, the jest.clearAllMocks() function is called to reset all mocked functions. This prevents any unintended side effects or interference between test cases.


afterEach(() => {
  jest.clearAllMocks();
});

Unit Test Descriptions:

  1. UserService.SignUpUser: This describe block contains two test cases related to user registration.

    • should create a new user and return formatted data: This test case verifies that the SignUpUser method correctly creates a new user when provided with valid input data. Mocked functions are used to simulate the expected behavior of the UserRepository and utility functions. The test asserts that the repository functions are called with the expected parameters and that the result contains the expected user ID and token.

it("should create a new user and return formatted data", async () => {
  // Test setup
  const userInputs: CreateUserInputType = {
    email: "test@example.com",
    password: "password",
    username: "testuser",
  };
  userRepositoryMock.FindUserByEmail.mockResolvedValue(null);
  (GenerateHashedPassword as jest.Mock).mockResolvedValue("hashedPassword");
  (GenerateUserToken as jest.Mock).mockReturnValue("token");
  (FormatData as jest.Mock).mockReturnValue({
    id: "123",
    token: "token",
  });

  // Perform the action
  const result = await userService.SignUpUser(userInputs);

  // Assertions
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledTimes(1);
  expect(userRepositoryMock.CreateUser).toHaveBeenCalledWith({
    ...userInputs,
    password: "hashedPassword",
  });
  expect(result.id).toBe("123");
  expect(result.token).toBeDefined();
});
  • should throw an error if user already exists: This test case ensures that an error is thrown when the SignUpUser method is called with an email that already exists in the database. The UserRepository's FindUserByEmail function is mocked to return a non-null value, simulating the scenario where a user with the given email already exists. The test checks that the FindUserByEmail function is called with the expected parameters and that CreateUser is not called.

it("should throw an error if user already exists", async () => {
  // Test setup
  const userInputs: CreateUserInputType = {
    email: "test@example.com",
    password: "password",
    username: "testuser",
  };
  userRepositoryMock.FindUserByEmail.mockResolvedValue({ _id: "existingId" });

  // Perform the action and assert the error
  await expect(userService.SignUpUser(userInputs)).rejects.toThrowError(
    "user already exists"
  );
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledTimes(1);
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledWith({
    email: userInputs.email,
  });
  expect(userRepositoryMock.CreateUser).not.toHaveBeenCalled();
});
  1. UserService.LoginUser: This describe block contains three test cases related to user login.

    • should login a user and return formatted data: This test case verifies that the LoginUser method successfully logs in a user when provided with valid credentials. Mocked functions are used to simulate the expected behavior of the UserRepository and utility functions. The test asserts that the repository functions are called with the expected parameters and that the generated token is included in the result.

it("should login a user and return formatted data", async () => {
  // Test setup
  const userInputs: LoginUserInputType = {
    email: "test@example.com",
    password: "password",
  };
  (ValidatePassword as jest.Mock).mockResolvedValue(true);
  (GenerateUserToken as jest.Mock).mockReturnValue("token");
  (FormatData as jest.Mock).mockReturnValue({
    id: "123",
    token: "token",
  });

  // Perform the action
  const result = await userService.LoginUser(userInputs);

  // Assertions
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledTimes(1);
  expect(GenerateUserToken).toHaveBeenCalledWith({
    payload: {
      id: "123",
    },
  });
  expect(result.id).toBe("123");
  expect(result.token).toBeDefined();
});
  • should throw an error if user email does not exist: This test case checks that an error is thrown when the LoginUser method is called with an email that does not exist in the database. The FindUserByEmail function of the UserRepository is mocked to return null, simulating the scenario where no user is found with the given email. The test verifies that the FindUserByEmail function is called with the expected parameters and that ValidatePassword is not called.

it("should throw an error if user email does not exist", async () => {
  // Test setup
  const userInputs: LoginUserInputType = {
    email: "test@example.com",
    password: "password",
  };
  (ValidatePassword as jest.Mock).mockResolvedValue(true);
  (userRepositoryMock.FindUserByEmail as jest.Mock).mockResolvedValue(null);

  // Perform the action and assert the error
  await expect(userService.LoginUser(userInputs)).rejects.toThrowError(
    "Invalid email or password"
  );
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledTimes(1);
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledWith({
    email: userInputs.email,
  });
  expect(ValidatePassword).not.toHaveBeenCalled();
});
  • should throw an error if user password does not match: This test case ensures that an error is thrown when the LoginUser method is called with a password that does not match the stored password in the database. The ValidatePassword function is mocked to return false, indicating a password mismatch. The test asserts that the FindUserByEmail and ValidatePassword functions are called with the expected parameters and that the FormatData function is not called.

it("should throw an error if user password does not match", async () => {
  // Test setup
  const userInputs: LoginUserInputType = {
    email: "test@example.com",
    password: "password",
  };
  (ValidatePassword as jest.Mock).mockResolvedValue(false);
  (FormatData as jest.Mock).mockReturnValue({
    id: "123",
    token: "token",
  });

  // Perform the action and assert the error
  await expect(userService.LoginUser(userInputs)).rejects.toThrowError(
    "Invalid user email or password"
  );
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledTimes(1);
  expect(userRepositoryMock.FindUserByEmail).toHaveBeenCalledWith({
    email: userInputs.email,
  });
  expect(ValidatePassword).toHaveBeenCalled();
  expect(FormatData).not.toHaveBeenCalled();
});

Best Practices and Recommendations:

  1. Mocking Dependencies: Mocking external dependencies, such as the UserRepository and utility functions, is a recommended practice for unit testing. It allows controlled testing of specific code paths and eliminates dependencies on external systems.

  2. Test Isolation: Each test case should be independent and isolated from others. Using a clean setup before each test and clearing mocked functions afterward ensures that tests do not interfere with each other and produce reliable results.

  3. Test Case Naming: Test case names should be descriptive and follow a consistent naming convention. They should clearly convey the expected behavior or outcome being tested.

  4. Clear Assertions: Each test case should include clear and concise assertions to validate the expected behavior. Assertions should cover all relevant aspects of the tested functionality.

  5. Mock Behavior Definition: When mocking functions, it's important to define their expected behavior based on the specific test case. This ensures that the mocked functions behave as intended and produce the desired results.

  6. Test Coverage: While the provided code includes unit tests for some scenarios, it's essential to strive for comprehensive test coverage. All critical code paths, edge cases, and potential failure scenarios should be covered by tests to minimize the risk of bugs.

Conclusion: Unit testing is an essential component of software development since it allows engineers to validate the integrity and dependability of individual code units. Unit tests for the UserService class are demonstrated in the given code, which covers user registration and login operations. The tests offer confidence in the correctness of the UserService implementation by using best practices such as mocking dependencies, isolating tests, and making explicit assertions. Continuous test coverage and adherence to best practices add to the codebase's overall quality and maintainability.