In our journey through isolating dependencies with test doubles, we've explored dummies, stubs, and spies. This lesson focuses on mocks, which are powerful test doubles capable of simulating external dependencies in software tests. Mocks can imitate the behavior of complex systems, allowing us to test code in isolation without relying on real and sometimes unpredictable systems like databases or web services.
Now, let's review the TDD workflow:
- Red: Write a failing test.
- Green: Write the minimum code to pass the test.
- Refactor: Improve the code structure without changing its behavior.
We'll demonstrate these principles using mocks, helping you to effectively isolate and test your application logic.
Mocks are indispensable in TDD because they allow you to test your code independently of the parts of the system you don't control. For instance, when writing tests for a PricingService
, you don't want tests to fail because an external currency conversion API goes down or changes unexpectedly. Mocks provide a controlled environment where you can simulate various conditions and responses as well as validate the calls.
Mocks, unlike spies, fully simulate the dependencies rather than simply observing their behavior. A mock creates a controlled substitute for a dependency, so the actual code or functionality isn’t executed. For instance, if a function interacts with an external API, a mock can simulate different responses from that API without making a network request.
Let's dive into mocking with Mockito. We'll start with the basics: how to mock a class and its methods.
Consider the IExchangeRateService
, which fetches exchange rates from an API. In testing the PricingService
, we need to mock this service to ensure our tests don't rely on actual API responses.
Here's a simple way to mock with Kotlin and Mockito
:
Kotlin1import org.junit.jupiter.api.Test 2import org.mockito.Mockito.* 3import org.mockito.junit.jupiter.MockitoExtension 4import org.junit.jupiter.api.extension.ExtendWith 5import kotlin.test.assertEquals 6 7@ExtendWith(MockitoExtension::class) 8class PricingServiceTest { 9 10 @Mock 11 lateinit var mockIExchangeRateService: IExchangeRateService 12 13 private val pricingService = PricingService(mockIExchangeRateService) 14 15 @Test 16 fun testConvertPriceUsesExchangeRate() { 17 // Arrange 18 `when`(mockIExchangeRateService.getRate("USD", "EUR")).thenReturn(1.5) 19 20 // Act 21 val result = pricingService.convertPrice(100.0, "USD", "EUR") 22 23 // Assert 24 verify(mockIExchangeRateService, times(1)).getRate("USD", "EUR") 25 assertEquals(150.00, result) 26 } 27}
In this setup:
- We use
Mockito
to mock theIExchangeRateService
. This mock object allows control over returned values and behavior without implementing the actual logic. - The line
`when`(mockIExchangeRateService.getRate("USD", "EUR")).thenReturn(1.5);
is used to simulate a method that returns a predefined value when called.
Using lateinit var
with @Mock
ensures that Mockito
recognizes it as a mock and includes methods like when
and verify
.
First, we define the IExchangeRateService
interface with a getRate
method that can throw an exception, ensuring our service accounts for potential failures or issues during currency rate retrieval.
Kotlin1interface IExchangeRateService { 2 @Throws(Exception::class) 3 fun getRate(fromCurrency: String, toCurrency: String): Double 4}
Next, we write the necessary code in the PricingService
to pass the test:
Kotlin1class PricingService(private val exchangeRateService: IExchangeRateService) { 2 3 @Throws(Exception::class) 4 fun convertPrice(amount: Double, fromCurrency: String, toCurrency: String): Double { 5 val rate = exchangeRateService.getRate(fromCurrency, toCurrency) 6 return Math.round(amount * rate * 100.0) / 100.0 7 } 8}
When the test runs, it should pass, as we've implemented just enough logic to meet the test's expectations.
Mocks aren't limited to simple returns; they can simulate complex interactions, handle exceptions, or even return different values based on input:
Kotlin1`when`(mockIExchangeRateService.getRate(anyString(), anyString())).thenAnswer { invocation -> 2 val from = invocation.getArgument<String>(0) 3 val to = invocation.getArgument<String>(1) 4 when { 5 from == "USD" && to == "EUR" -> 0.85 6 from == "USD" && to == "GBP" -> 0.73 7 else -> 1.0 8 } 9}
This flexibility allows for comprehensive testing of edge cases and alternative paths, ensuring your application handles real-world scenarios robustly.
In this lesson, we explored the power of mocks within the TDD framework, using Kotlin, JUnit, and Mockito
. By isolating dependencies using mocks, we can ensure our tests are reliable and focused on our application's logic:
- Red: Begin with a failing test to clarify the functionality you're developing.
- Green: Implement the minimum necessary code using mocks to pass the test.
- Refactor: Clean and refine code without changing its behavior.
You're now ready to enter the practice exercises, where you'll apply these concepts to further solidify your understanding. By mastering these skills, you're well on your way to creating more robust and scalable applications. Keep practicing, and congratulations on reaching this point in the course!
