Lesson 3
Utilizing Spies in Test Driven Development with Mockito
Introduction and Context Setting

Welcome to our third lesson on Test-Driven Development (TDD) with Test Doubles. In this lesson, we focus on Spies, an essential test double type used to observe interactions with your code's dependencies during testing. By now, you've already been introduced to Dummies and Stubs in previous lessons, allowing you to manage dependencies via test doubles effectively.

Our goal here is to seamlessly integrate Spies into the TDD Red-Green-Refactor cycle: writing tests (Red), creating a minimal implementation (Green), and refactoring for better quality without altering behavior. Let's dive into understanding and using Spies within this framework!

Deep Dive into Spies in Mockito

Spies in Mockito allow you to observe and record how functions in your application are used without modifying their behavior. In TDD, Spies help verify that functions are called when and how you expect them to be, which is a vital part of writing reliable tests.

Spies can check:

  • If a method was called
  • How many times it was called
  • With what arguments it was called

Spies align perfectly with the Red-Green-Refactor cycle:

  • Red: Write a failing test to ensure your code's behavior is verified.
  • Green: Implement only enough code for the tests to pass.
  • Refactor: Clean up the tests and the implementation for better software design.

Let's move on to setting up our environment, utilizing the powerful features of Mockito.

Example: Implementing the First Spy

Let's consider a Notification Service example where we aim to ensure notifications are sent with appropriate priorities. We will begin by implementing a Spy on the send method of the RealNotificationSender using Mockito. This allows you to use an actual dependency within your test but verify how it was called.

Here's an example test file: NotificationServiceTest.java.

Java
1public class NotificationServiceTest { 2 3 private RealNotificationSender senderSpy; 4 private NotificationService notificationService; 5 6 @Before 7 public void setUp() { 8 // Arrange 9 RealNotificationSender realSender = new RealNotificationSender(); 10 senderSpy = Mockito.spy(realSender); 11 12 notificationService = new NotificationService(senderSpy); 13 } 14 15 // Test cases to follow... 16}

Here's the key parts of the above code:

  • We create an actual instance of the RealNotificationSender.
  • We create a Spy object of RealNotificationSender using Mockito.spy, which represents our Spy.
  • This Spy will help us verify interactions with the send method.

Next, we insert failing tests to see our Spies in action. Remember that writing failing tests is our "Red" step in TDD.

Example: Testing with Spies

Let's add some test cases to observe the behavior of the send method using our Mockito Spy.

Java
1public class NotificationServiceTest { 2 3 // ... (setup as before) 4 5 @Test 6 public void testNotifyUserSendsNotificationWithLowPriorityForShortMessages() throws Exception { 7 // Act 8 notificationService.notifyUser("user1", "Hi!"); 9 10 // Assert 11 verify(senderSpy, times(1)).send(any(Notification.class)); 12 13 verify(senderSpy, times(1)).send(argThat(notification -> 14 notification.getUserId().equals("user1") && 15 notification.getMessage().equals("Hi!") && 16 notification.getPriority() == Priority.LOW 17 )); 18 } 19}

Here:

  • We create a failing test to ensure our notification sends with low priority.
  • The Mockito verify method captures and observes interactions of the send method.
  • times(1) verifies how often it was called, while any(Notification.class) checks for any parameters as long as it receives a Notification.

Upon running this test, it will fail initially as we have not written the corresponding implementation. This is the Red step of our cycle. Implementing minimal logic in the NotificationService class will move us towards making the test pass (Green).

Understanding Mockito's Verify Functionality

Mockito's verify function is a powerful tool for asserting that specific interactions with mock objects occurred as expected during a test. It allows you to check whether a particular method was called on the mock object, how many times it was called, and with what parameters.

In the first verification step from the test:

Java
1verify(senderSpy, times(1)).send(any(Notification.class));
  • verify(senderSpy, times(1)): Ensures that the send method on senderSpy was called exactly one time.
  • send(any(Notification.class)): Uses any(Notification.class) as a matcher, indicating that the method must have been called with any instance of the Notification class.

In the second verification step:

Java
1verify(senderSpy, times(1)).send(argThat(notification -> 2 notification.getUserId().equals("user1") && 3 notification.getMessage().equals("Hi!") && 4 notification.getPriority() == Priority.LOW 5));
  • verify(senderSpy, times(1)): Ensures that the send method on senderSpy was called exactly one time.
  • argThat(notification -> ...): Uses a predicate to perform a detailed check on the Notification object, verifying that:
    • getUserId() returns "user1".
    • getMessage() returns "Hi!".
    • getPriority() returns Priority.LOW.

These assertions help ensure that the code behaves correctly by verifying interactions with each dependency, crucial in a test-driven development approach.

Making the Test Pass (Green)

After writing a failing test to capture how our send method should behave, the next step is to implement the minimal functionality required to make the test pass. This involves modifying the NotificationService class to utilize the send method correctly based on the specified logic.

Here's an example of updating the NotificationService to fulfill the test requirements:

Java
1public class NotificationService { 2 3 private final INotificationSender sender; 4 5 public NotificationService(INotificationSender sender) { 6 this.sender = sender; 7 } 8 9 public void notifyUser(String userId, String message) { 10 sender.send(new Notification(userId, message, Priority.LOW)); 11 } 12}
  • The send method is invoked with the userId, message, and priority.
  • Since there is only one test, this is the minimal amount of code necessary to get the test to pass.

With this implementation, re-running the test should now pass, taking us from the Red to the Green phase of TDD.

Using Annotations with Spies

Similar to Stubs, Mockito provides annotations for creating spies as well. Here's an example of using @Spy and @InjectMocks with Mockito:

Java
1@ExtendWith(MockitoExtension.class) 2public class NotificationServiceTests { 3 4 @Spy 5 private RealNotificationSender senderSpy = new RealNotificationSender(); 6 7 @InjectMocks 8 private NotificationService notificationService; 9 10 // Test cases 11}

In the above code snippet:

  • @Spy creates a Spy for RealNotificationSender.
  • @InjectMocks automatically injects the Spy into NotificationService.
  • @ExtendWith(MockitoExtension.class) enables Mockito annotations in JUnit 5.

This approach reduces boilerplate while maintaining clarity in test logic.

Review, Summary, and Next Steps

In this lesson, we've explored the importance of Spies as test doubles in the TDD process using Java, JUnit, and Mockito. Here's what we've achieved:

  • Red: Set up tests simulating real-world scenarios, initially leading them to fail.
  • Green: We strategized to write the least amount of functionality to fulfill all test criteria.
  • Refactor: Ensured our code and tests remain clean, scalable, and comprehensible.

As we transition into practice exercises, focus on implementing Spies in different scenarios to solidify these concepts. The exercises will guide you in writing, verifying, and refactoring tests with Spies, empowering you to integrate this knowledge into complex projects.

Happy testing and enjoy the lessons you've built through TDD in Java with JUnit and Mockito!

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