Welcome to the third lesson of the Increasing Code Test Coverage course! In our previous lessons, we explored the importance of code test coverage and how characterization tests can help document existing behavior. Now, we will focus on increasing testability by using traits and mocks.
This lesson will guide you through the process of decoupling dependencies, which is crucial for writing effective and reliable tests. By the end of this lesson, you'll understand how to refactor code to make it more testable and how to use Mockito
's mocking capabilities to create mock objects for testing.
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.
Traits play a crucial role in decoupling dependencies. By defining a trait, you create a contract that different implementations can adhere to. This allows you 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 EmailService
trait. This trait can then be implemented by the EmailService
class.
Here's the EmailService
in question:
Scala1class EmailService: 2 def sendOrderConfirmation(order: Order): Unit = 3 println("[Sending Email] This should not happen in tests!")
This class can be adjusted to adhere to a trait that defines the contract for the relevant class:
Scala1trait EmailService: 2 def sendOrderConfirmation(order: Order): Unit 3 4object RealEmailService extends EmailService: 5 override def sendOrderConfirmation(order: Order): Unit = 6 println("[Sending Email] This should not happen in tests!")
By using the EmailService
trait, we can easily swap out the real EmailService
with a mock during testing, allowing us to test the OrderProcessor
without sending actual emails.
Mocks are a type of test double that allows you to simulate the behavior of real objects. Mockito
provides capabilities for creating mock objects. It enables you to define how a mock should behave and verify interactions with it. For example, we can use Mockito
to create a mock of the EmailService
and verify that the sendOrderConfirmation
method is called during the processOrder
method of the OrderProcessor
.
Here's how you can set up a mock using Mockito
:
Scala1import org.scalatest.funspec.AnyFunSpec 2import org.scalatestplus.mockito.MockitoSugar 3import org.mockito.Mockito.* 4 5class OrderProcessorSpec extends AnyFunSpec with MockitoSugar: 6 describe("OrderProcessor"): 7 it("should call sendOrderConfirmation when processing an order"): 8 // Arrange 9 val mockEmailService: EmailService = mock[EmailService] 10 val orderProcessor: OrderProcessor = OrderProcessor(mockEmailService) 11 val order: Order = Order(List(OrderItem(BigDecimal(10.00), 1))) 12 13 // Act 14 orderProcessor.processOrder(order) 15 16 // Assert 17 verify(mockEmailService).sendOrderConfirmation(order)
In this example, we create a mock of the EmailService
. This allows us to use any code that requires the EmailService
trait with full control over what happens when calling members of said trait.
Refactoring code to use traits and mocks can significantly enhance testability. Let's walk through the process of refactoring the OrderProcessor
class to accept an EmailService
trait. The initial implementation could look something like this:
Scala1class OrderProcessor: 2 private val emailService = RealEmailService 3 4 def processOrder(order: Order): Boolean = 5 // ... order processing logic ... 6 7 // Send confirmation email 8 emailService.sendOrderConfirmation(order) 9 10 true
With our trait defined above, we can now create a more testable implementation using a companion object:
Scala1object OrderProcessor: 2 def apply(emailService: EmailService = RealEmailService): OrderProcessor = 3 new OrderProcessor(emailService) 4 5class OrderProcessor private (emailService: EmailService): 6 def processOrder(order: Order): Boolean = 7 // ... order processing logic ... 8 emailService.sendOrderConfirmation(order) 9 true
By using the EmailService
trait and a companion object with an apply
method, the OrderProcessor
can now work with any implementation of the trait, making it more flexible and easier to test. The private constructor ensures that instances are created through the companion object, providing a clean API for creating new instances.
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 you didn't break any existing behavior as we refactor. We will also add a new test to test a behavior previously not covered.
Remember, the key points to focus on are decoupling dependencies using traits 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 your code. Good luck, and enjoy the exercises!
