The Case Against Mocking Libraries

Shubham Sharma - Aug 21 - - Dev Community

Clean Code: The Case Against Mocking Libraries

In the world of software testing, mocking libraries have long been a popular tool for isolating components and simulating dependencies. However, as our understanding of clean code and maintainable tests evolves, there's a growing sentiment that over-reliance on mocking libraries can lead to brittle, hard-to-maintain test suites. This article explores why you might want to reconsider your use of mocking libraries and opt for custom fakes instead.

The Problem with Mocking Libraries

  1. Ugly Syntax: Many mocking libraries introduce their own DSL (Domain Specific Language) for setting up mocks. This can make tests harder to read and understand, especially for developers who aren't intimately familiar with the mocking framework.

  2. Tight Coupling to Implementation: Mocks often require detailed knowledge of the internal workings of the system under test. This can lead to tests that are tightly coupled to implementation details, making them fragile and prone to breaking when refactoring.

  3. Overuse and Abuse: While mocking libraries can be useful for verifying specific interactions, they're often overused. Developers may find themselves mocking every dependency, leading to tests that are more about the mocks than the actual behaviour being tested.

  4. Inconsistent Assumptions: When mocks are scattered throughout a test suite, each mock may make different assumptions about how a dependency should behave. This can lead to inconsistencies and make it harder to reason about the expected behaviour of the system.

The Case for Custom Fakes

Instead of relying on mocking libraries, consider creating your own fake implementations of dependencies. Here's why:

  1. Cleaner Syntax: Custom fakes use plain language constructs, making them easier to read and understand without knowledge of a specific mocking framework.

  2. Reduced Coupling: Fakes can be designed to mimic the public interface of a dependency without exposing implementation details, reducing coupling between tests and production code.

  3. Consistent Behaviour: By creating a single fake implementation of a dependency, you ensure consistent behaviour across all tests that use that dependency.

  4. Less Code: Tests using fakes often require less setup code compared to those using mocking libraries, leading to more concise and focused tests.

  5. Better Encapsulation: Fakes allow you to encapsulate complex behaviour in a reusable way, which can be especially useful for simulating external services or complex components.

Example: Refactoring from Mocks to Fakes

Let's look at an example of how we can refactor a test from using mocks to using a custom fake:

// Using a mocking library
import { jest } from '@jest/globals';

interface IUser {}
interface IUserStore {
  findById(id: string): IUser | undefined;
  store(user: IUser): void;
}

class User implements IUser {}

class UserService {
  constructor(private userStore: IUserStore) {}

  activate(user: IUser): void {
    this.userStore.store(user);
  }
}

test('UserService should activate user - using mocks', () => {
  // Arrange
  const user = new User();
  const userStoreMock = {
    findById: jest.fn().mockReturnValue(user),
    store: jest.fn(),
  };

  const userService = new UserService(userStoreMock);

  // Act
  userService.activate(user);

  // Assert
  expect(userStoreMock.store).toHaveBeenCalledTimes(1);
  expect(userStoreMock.store).toHaveBeenCalledWith(user);
});

// Using a custom fake
class UserStoreFake implements IUserStore {
  users: IUser[] = [];

  constructor(user?: IUser) {
    if (user) {
      this.users.push(user);
    }
  }

  findById(id: string): IUser | undefined {
    // Simplified for example, would likely use ID in a real implementation
    return this.users[0];
  }

  store(user: IUser): void {
    this.users.push(user);
  }
}

test('UserService should activate user - using a fake', () => {
  // Arrange
  const user = new User();
  const userStoreFake = new UserStoreFake(user);
  const userService = new UserService(userStoreFake);

  // Act
  userService.activate(user);

  // Assert
  const storedUser = userStoreFake.users.slice(-1)[0];
  expect(storedUser).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

In the refactored version, we've replaced the mock with a custom UserStoreFake. This fake implementation can be reused across multiple tests, ensuring consistent behaviour and reducing the amount of setup code needed in each test.

Conclusion

While mocking libraries have their place in the testing toolkit, they should be used judiciously. By favouring custom fakes over mocks, we can create cleaner, more maintainable, and more resilient test suites. This approach encourages us to think more deeply about the contracts between components and helps ensure that our tests remain valuable as our codebase evolves.

Remember, the goal of testing is not just to increase code coverage, but to provide confidence in the behaviour of our system. By writing cleaner tests with custom fakes, we can achieve this goal more effectively and with less maintenance overhead.

. .
Terabox Video Player