Mocking vs. stubbing - Choosing the right test double

Lewis -

Test doubles are vital tools in our testing arsenal, helping us write effective unit tests by replacing real dependencies with simulated ones.

Two of the most common test doubles are mocks and stubs, but choosing between them isn't always straightforward

Understanding test doubles

Firstly, it's worth understanding what test doubles are. Test doubles help us isolate the code we're testing and make our tests more reliable and maintainable.

Think of them like stunt doubles in movies – they stand in for the real thing when it's impractical or unnecessary to use the actual implementation.

What are stubs?

Stubs are the simpler of the two test doubles. They provide predefined responses to method calls, making them perfect for simulating dependencies that return data. Stubs don't care about behavior – they simply return what you tell them to return.

Here's an example of a stub in action:

// Real service
class UserService {
  async fetchUserProfile(userId) {
    // Complex database operations
    const response = await database.query(userId);
    return response.data;
  }
}

// Test using a stub
describe('Profile Component', () => {
  it('displays user information', async () => {
    // Creating a stub
    const userServiceStub = {
      fetchUserProfile: async () => ({
        id: 1,
        name: 'John Doe',
        email: 'john@example.com'
      })
    };

    const profile = new ProfileComponent(userServiceStub);
    await profile.load(1);

    expect(profile.userName).toBe('John Doe');
  });
});

What are mocks?

Mocks are more sophisticated test doubles that not only return predefined values but also verify how they're used. They help us ensure that our code interacts with its dependencies in the expected way. Mocks are particularly useful when testing behavior rather than just state.

Here's an example using a mock:

// Real service
class EmailService {
  async sendWelcomeEmail(user) {
    // Complex email sending logic
    await this.emailProvider.send({
      to: user.email,
      subject: 'Welcome!',
      body: `Welcome ${user.name}!`
    });
  }
}

// Test using a mock
describe('User Registration', () => {
  it('sends welcome email with correct data', async () => {
    // Creating a mock
    const emailServiceMock = {
      sendWelcomeEmail: jest.fn()
    };

    const registration = new UserRegistration(emailServiceMock);
    await registration.register({
      name: 'Jane Doe',
      email: 'jane@example.com'
    });

    // Verifying the interaction
    expect(emailServiceMock.sendWelcomeEmail).toHaveBeenCalledWith({
      name: 'Jane Doe',
      email: 'jane@example.com'
    });
  });
});

When to use stubs

Stubs are ideal when:

  • Your test focuses on the state of the system under test rather than its interactions.
  • You need to simulate data retrieval from external services or databases.
  • You want to test how your code handles different response scenarios (success, error, empty data).
  • The interaction with the dependency is simple and one-directional.

When to use mocks

Mocks are better suited when:

  • You need to verify that specific methods were called with particular arguments.
  • The interaction between your code and its dependencies is complex or bidirectional.
  • You're testing command objects or services that perform actions rather than return data.
  • You need to ensure that certain side effects occur in a specific order.

Making the right choice

The key to choosing between mocks and stubs lies in understanding what you're trying to test. Ask yourself:

Am I testing state or behavior?

If you're primarily interested in how your code transforms data or handles different scenarios, use stubs. If you're more concerned with how your code interacts with its dependencies, use mocks.

Consider this practical example:

// Testing state with a stub
describe('Price Calculator', () => {
  it('applies discount for premium users', async () => {
    const userServiceStub = {
      getUserType: async () => 'premium'
    };

    const calculator = new PriceCalculator(userServiceStub);
    const finalPrice = await calculator.calculatePrice(100);

    expect(finalPrice).toBe(80); // 20% discount
  });
});

// Testing behavior with a mock
describe('Order Processor', () => {
  it('notifies shipping service for large orders', async () => {
    const shippingServiceMock = {
      scheduleDelivery: jest.fn()
    };

    const processor = new OrderProcessor(shippingServiceMock);
    await processor.process({
      items: ['item1', 'item2'],
      total: 500
    });

    expect(shippingServiceMock.scheduleDelivery).toHaveBeenCalled();
  });
});

Common pitfalls to avoid

Don't over-mock: Using too many mocks can make your tests brittle and harder to maintain. Stick to mocking only the interfaces that are essential to your test.

Avoid testing implementation details: Focus on testing the behavior that matters to users of your code, not the internal implementation details.

Don't mix concerns: Keep your test doubles focused. A test double should either be a mock or a stub, not both.

Conclusion

Both mocks and stubs have their place in testing, and understanding their strengths helps us write better tests. Remember that stubs are for simulating state and returning data, while mocks are for verifying behavior and interactions. Choose the right tool based on what you're trying to test and your tests will be more effective and maintainable.

Related Posts