Lesson 4
Increasing Testability - Adding Interfaces and Mocks
Introduction

Welcome! In this lesson, we will focus on enhancing testability by using interfaces and mocks. This lesson will guide us through the process of decoupling dependencies, which is crucial for writing effective and reliable tests. By the end of this lesson, we'll understand how to refactor code to make it more testable and how to use the Moq library to create mock objects for testing.

Understanding the Problem

Testing code with tightly coupled dependencies can be challenging. When a class directly depends on external services, such as email or database services, it becomes difficult to isolate the code under test. This can lead to unreliable and non-repeatable tests. For instance, if our OrderProcessor class directly calls an EmailService to send confirmation emails, testing the OrderProcessor without actually sending emails becomes problematic. The goal is to isolate the code under test to ensure that our tests are reliable and repeatable, while not triggering side effects and not depending on logic that isn't being tested directly.

Introducing Interfaces

Interfaces play a crucial role in decoupling dependencies. By defining an interface, we create a contract that different implementations can adhere to. This allows us to substitute real implementations with test doubles, such as mocks or stubs, during testing. For example, instead of directly using an EmailService in our OrderProcessor, we can define an IEmailService interface. This interface can then be implemented by the EmailService class. Here's the EmailService in question:

C#
1public class EmailService 2{ 3 public void SendOrderConfirmation(Order order) 4 { 5 Console.WriteLine("[Sending Email]This should not happen in tests!"); 6 } 7}

This class can be adjusted to adhere to an interface that defines the contract for the relevant class:

C#
1public interface IEmailService 2{ 3 void SendOrderConfirmation(Order order); 4} 5 6public class EmailService : IEmailService 7{ 8 public void SendOrderConfirmation(Order order) 9 { 10 Console.WriteLine("[Sending Email]This should not happen in tests!"); 11 } 12}

By using the IEmailService interface, we can easily swap out the real EmailService with a mock during testing, allowing us to test the OrderProcessor without sending actual emails.

Implementing Mocks with Moq Library

Mocks are a type of test double that allows us to simulate the behavior of real objects. The Moq library is a popular tool for creating mock objects in .NET. It enables us to define how a mock should behave and verify interactions with it. For example, we can use Moq to create a mock of the IEmailService and verify that the SendOrderConfirmation method is called during the ProcessOrder method of the OrderProcessor. Here's how we can set up a mock using Moq:

C#
1var mockEmailService = new Mock<IEmailService>(); 2var objectToUseAsMockDependency = mockEmailService.Object;

In this example, we create a mock of the IEmailService. This allows us to use any code that requires the IEmailService interface with full control over what happens when calling members of said interface.

Setting Up a Mock with Behavior

To demonstrate how a mock can be set up with specific behavior, consider the following example where we configure the mock to simulate the SendOrderConfirmation method:

C#
1mockEmailService.Setup(service => service.SendOrderConfirmation(It.IsAny<Order>())) 2 .Callback<Order>(order => Console.WriteLine($"Mock email sent for order: {order.Id}"));

In this setup, we use the Setup method to define the behavior of the SendOrderConfirmation method. The It.IsAny<Order>() is a matcher that allows the method to accept any Order object. The Callback method is used to specify what should happen when SendOrderConfirmation is called, in this case, printing a message to the console.

Verifying Interactions with Mocks

After setting up the mock, we can also verify that certain interactions occurred during the test. Here's how we can verify that the SendOrderConfirmation method was called exactly once:

C#
1mockEmailService.Verify(service => service.SendOrderConfirmation(It.IsAny<Order>()), Times.Once);

This verification step ensures that the SendOrderConfirmation method was called exactly once during the test execution. By using Moq to set up and verify mocks, we can effectively simulate and test interactions with dependencies, enhancing the testability of our code.

Refactoring Code for Testability

Refactoring code to use interfaces and mocks can significantly enhance testability. Let's walk through the process of refactoring the OrderProcessor class to accept an IEmailService interface. The initial implementation could look something like this:

C#
1public class OrderProcessor 2{ 3 private readonly EmailService _emailService = new EmailService(); 4 5 public bool ProcessOrder(Order order) 6 { 7 // ... order processing logic ... 8 9 // Send confirmation email 10 _emailService.SendOrderConfirmation(order); 11 12 return true; 13 } 14}

With our interface defined above, we can now add a constructor that accepts the IEmailService interface as a parameter, while still maintaining backwards compatibility for other parts of the system that rely on the current implementation:

C#
1public class OrderProcessor 2{ 3 private readonly IEmailService _emailService; 4 5 public OrderProcessor(IEmailService emailService = null) 6 { 7 _emailService = emailService ?? new EmailService(); 8 } 9 10 public bool ProcessOrder(Order order) 11 { 12 // ... order processing logic ... 13 14 // Send confirmation email 15 _emailService.SendOrderConfirmation(order); 16 17 return true; 18 } 19}

Notice that we also changed the type used in the read-only field within the OrderProcessor class. By using the IEmailService interface, the OrderProcessor can now work with any implementation of the interface, making it more flexible and easier to test.

When making these changes, we should keep in mind the idea of minimum viable change. By focusing on the smallest possible change that achieves the desired outcome, we can ensure that the existing functionality remains intact, minimizing disruptions to the established codebase. This approach also facilitates easier code reviews and testing, as smaller changes are more manageable and easier to understand. Additionally, it promotes a more agile development process, allowing teams to iterate quickly and respond to feedback without overhauling large portions of the code. Ultimately, making minimal changes supports a more efficient and reliable development workflow, leading to higher quality software.

Practical Application and Review

Now that we've covered the theory, it's time to put it into practice. In the upcoming exercises, we'll have the opportunity to apply these techniques to the provided code examples. We'll have an initial set of tests that help us confirm that we didn't break any existing behavior as we refactor. We will also add a new test to test a behavior previously not covered. Focus on decoupling dependencies using interfaces and leveraging the power of mocks to simulate real objects during testing. This approach not only increases testability but also enhances the overall quality and reliability of our code. Good luck, and enjoy the exercises!

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.