Welcome to the second lesson in our exploration of Test-Driven Development (TDD) using Java, JUnit, and Mockito. In the previous lesson, we covered how to utilize dummies to isolate dependencies. This lesson will focus on learning about another type of test double — Stubs.
By the end of this lesson, you will understand what stubs are and how to implement them in your tests, specifically for isolating dependencies like external services within the Java ecosystem.
In testing, test doubles help us isolate parts of our application. We've previously discussed dummies; now, we will explore stubs, a more useful type of test double. Stubs provide predefined answers to method calls during testing. Unlike other test doubles, stubs do not track their usage, making them simpler yet powerful for certain scenarios.
Stubs are particularly useful when testing functions that rely on external services or complex dependencies. By simulating function outputs, stubs make tests faster and more predictable. Keep in mind that stubs focus on ensuring your application's logic functions as expected without verifying the correctness of external dependencies.
In Java, Mockito is a popular library used to create stubs. It allows us to set up return values that simulate how dependencies should behave in a controlled environment. This predictability isolates and tests your application's logic without relying on the behavior of external systems, which might be complex or introduce variability.
To illustrate the concept of stubs, we will create a WeatherAlertService
using stubs in a test-driven development process.
We will build a WeatherAlertService
that fetches data from a WeatherService
. This service will issue alerts based on specific conditions. The external data source is impractical for testing, so we'll use stubbed data for our tests instead.
Create a new test class named WeatherAlertServiceTest.java
with the following test setup:
Java1import org.junit.jupiter.api.BeforeEach; 2import org.junit.jupiter.api.Test; 3 4import static org.junit.jupiter.api.Assertions.assertEquals; 5 6public class WeatherAlertServiceTest { 7 8 private WeatherServiceStub weatherService; 9 private WeatherAlertService alertService; 10 11 class WeatherServiceStub implements IWeatherService { 12 private WeatherData currentWeather = new WeatherData(20, "sunny"); 13 14 public void setWeather(int temperature, String conditions) { 15 this.currentWeather = new WeatherData(temperature, conditions); 16 } 17 18 @Override 19 public WeatherData getCurrentWeather(String location) { 20 return currentWeather; 21 } 22 } 23 24 @BeforeEach 25 public void setUp() { 26 weatherService = new WeatherServiceStub(); 27 alertService = new WeatherAlertService(weatherService); 28 } 29 30 @Test 31 public void testShouldReturnHeatWarningWhenTemperatureIsAbove35() { 32 // Arrange 33 weatherService.setWeather(36, "sunny"); 34 35 // Act 36 String alert = alertService.shouldSendAlert("London"); 37 38 // Assert 39 assertEquals("Extreme heat warning. Stay hydrated!", alert); 40 } 41}
In this test:
- We create a hand-crafted stub
WeatherServiceStub
that implements theIWeatherService
interface. This stub allows us to customize the weather data by setting predefined values fortemperature
andconditions
via thesetWeather
method. - The stub’s
getCurrentWeather
method returns aWeatherData
object with these predefined values, simulating expected weather conditions for the test scenario without using Mockito. - The JUnit test validates whether the
WeatherAlertService
correctly interprets the weather data and returns the appropriate alert, specifically checking if it generates a heat warning when the temperature exceeds 35 degrees.
Run this test, and expect it to fail initially, as WeatherAlertService
is not yet implemented to handle the specified conditions.
Implement the WeatherAlertService
class with minimal logic:
Java1public class WeatherAlertService { 2 private final IWeatherService weatherService; 3 4 public WeatherAlertService(IWeatherService weatherService) { 5 this.weatherService = weatherService; 6 } 7 8 public String shouldSendAlert(String location) { 9 WeatherData weather = weatherService.getCurrentWeather(location); 10 11 if (weather.getTemperature() > 35) { 12 return "Extreme heat warning. Stay hydrated!"; 13 } 14 15 return null; 16 } 17}
Also, implement the supporting interfaces and classes:
Java1public interface IWeatherService { 2 WeatherData getCurrentWeather(String location); 3}
Java1public class WeatherData { 2 private final int temperature; 3 private final String conditions; 4 5 public WeatherData(int temperature, String conditions) { 6 this.temperature = temperature; 7 this.conditions = conditions; 8 } 9 10 public int getTemperature() { 11 return temperature; 12 } 13 14 public String getConditions() { 15 return conditions; 16 } 17}
Run the tests again. The objective is to pass the specific test scenario by implementing only the necessary logic without overengineering other cases.
In the initial implementation, we created a manual stub, WeatherServiceStub
, to simulate weather data. Now, we will refactor this by leveraging the Mockito library to streamline the process and improve maintainability. Using Mockito, we can create flexible and reusable stubs with minimal code.
The Mockito library allows us to set up method expectations and return values dynamically. By substituting our hand-crafted stub with a Mockito-based one, we achieve the same test outcomes but with enhanced clarity and simplicity. This approach decreases the potential for bugs and makes adjusting test parameters easier.
Here's how we refactor our WeatherAlertServiceTests
:
- Replace the
WeatherServiceStub
with amock(IWeatherService.class)
. - Use the
when
andthenReturn
methods inMockito
to define the expected inputs and outputs for thegetCurrentWeather
method. - Through
Mockito
, we eliminate the need to manage state explicitly within our custom stub class.
The refactored test class using the Mockito
library is shown below:
Java1import org.junit.jupiter.api.BeforeEach; 2import org.junit.jupiter.api.Test; 3import org.mockito.Mockito; 4 5import static org.mockito.Mockito.mock; 6import static org.mockito.Mockito.when; 7import static org.junit.jupiter.api.Assertions.assertEquals; 8 9public class WeatherAlertServiceTest { 10 11 private IWeatherService weatherServiceMock; 12 private WeatherAlertService alertService; 13 14 @BeforeEach 15 public void setUp() { 16 weatherServiceMock = mock(IWeatherService.class); 17 alertService = new WeatherAlertService(weatherServiceMock); 18 } 19 20 @Test 21 public void shouldReturnHeatWarningWhenTemperatureIsAbove35() { 22 // Arrange 23 when(weatherServiceMock.getCurrentWeather("London")) 24 .thenReturn(new WeatherData(36, "sunny")); 25 26 // Act 27 String alert = alertService.shouldSendAlert("London"); 28 29 // Assert 30 assertEquals("Extreme heat warning. Stay hydrated!", alert); 31 } 32}
This transition toward using Mockito
simplifies test management and focuses the tests more on behavior rather than setup intricacies.
Alternatively, you can use annotations like @Mock
and @InjectMocks
along with the @ExtendWith(MockitoExtension.class)
annotation to further simplify setup and initialization:
Java1import org.junit.jupiter.api.extension.ExtendWith; 2import org.mockito.InjectMocks; 3import org.mockito.Mock; 4import org.mockito.junit.jupiter.MockitoExtension; 5 6@ExtendWith(MockitoExtension.class) 7public class WeatherAlertServiceTest { 8 9 @Mock 10 private IWeatherService weatherServiceMock; 11 12 @InjectMocks 13 private WeatherAlertService alertService; 14 15 // Test methods... 16}
The @ExtendWith(MockitoExtension.class)
annotation activates Mockito annotations like @Mock
and @InjectMocks
. The @Mock
annotation creates a mock instance of IWeatherService
, and @InjectMocks
automatically injects this mock into the WeatherAlertService
.
This approach eliminates the need for manual mock initialization and makes the tests cleaner and more maintainable. It also reduces boilerplate code, allowing you to focus more on the behavior being tested.
In this lesson, we delved into the concept of stubs and how they serve as a practical method to isolate dependencies in tests. Here are the key takeaways:
- Stubs offer a way to replace external dependencies by providing predefined return values, facilitating the testing of functionalities reliant on services beyond our immediate control.
- Through the Red-Green-Refactor cycle, we emphasized writing tests that initially fail, implementing just enough logic to pass the tests, and refining the code consistently.
- Using Mockito in Java allows us to create stubs and mocks efficiently, leading to cleaner and more maintainable tests.
Prepare to apply these techniques in forthcoming exercises, where you’ll explore different scenarios utilizing stubs. This will enhance your skills in employing test doubles and adhering to the TDD methodology, ultimately advancing your ability to create dependable, thoroughly-tested code.