Hello! Welcome to the first lesson of our module on Testing Strategies for Microservices.
In our previous module, we focused on observability—learning how to monitor, diagnose, and troubleshoot our services once they are in production. We learned to use logs, metrics, and traces to find out what went wrong and why. Now, we're shifting our focus from fixing problems to preventing them. High-quality testing is the bedrock of building reliable, production-ready applications, a non-negotiable skill for the senior roles you're targeting.
Today's lesson addresses the foundational layer of the testing pyramid: the unit test. Our learning outcome is to implement unit tests for a Spring Boot microservice using JUnit and Mockito to isolate component behavior.
We'll break down how to test a single piece of your application—like a service class—in complete isolation from its dependencies, such as databases or other microservices. Mastering this is crucial for interviews, as it demonstrates your commitment to code quality and your ability to create maintainable and robust software.
1. The Anatomy of a Unit Test
A unit test focuses on the smallest testable part of an application, known as the System Under Test (SUT). This is often a single method or a class. The primary goal is to verify that this unit works as expected in isolation.
To do this in the Spring ecosystem, we use two key libraries:
- JUnit 5: The de facto standard Java testing framework that provides the structure for writing and running tests (e.g., the
@Testannotation). - Mockito: A mocking framework that lets us create "fake" versions of our dependencies. This is the key to achieving isolation.
Let's start by looking at the fundamental structure of a JUnit 5 test.
Spring Boot Testing Tutorial - Part 1 | Unit Testing with JUnit 5 and Mockito
This video, 'Spring Boot Testing Tutorial - Part 1', provides an excellent step-by-step guide. We'll start with the basics of setting up a test and writing our first assertions.
Watch the section from 02:44 to 07:11. This will walk you through creating a test for a simple service method that has no external dependencies. Pay attention to the use of @Test, @DisplayName, and the different types of assertions for both happy paths and exception handling.
As you saw, a basic test involves:
- Instantiating the class you want to test (the SUT).
- Calling the method with specific inputs.
- Asserting that the output (or behavior) is what you expect.
Writing More Readable Assertions with AssertJ
While JUnit's built-in assertions work, modern practice often favors libraries that offer a more readable, "fluent" API. AssertJ is one of the most popular.
Spring Boot Testing Tutorial - Part 1 | Unit Testing with JUnit 5 and Mockito
Let's continue with the same video to see how AssertJ can make your tests cleaner and more expressive.
Watch the segment from 06:57 to 08:50. Notice how assertions like assertThat(result).isFalse() or assertThatThrownBy(...) read almost like plain English.
Using a fluent assertion library like AssertJ is a sign of a seasoned developer. The chainable methods make it easier to write and, more importantly, read complex assertions.
2. Achieving Isolation with Mockito
The real challenge in unit testing arises when your SUT has dependencies. For example, a ProductService might depend on a ProductRepository to fetch data from a database. In a unit test, we do not want to connect to a real database. It would be slow, fragile, and violate the principle of isolation.
This is where Mockito comes in. We use it to create a mock—a dummy implementation of the ProductRepository that we can control completely.
Java 23, SpringBoot 3.3.4: AI-Driven: JUnit 5, Mockito — Part 3
This article, 'Java 23, SpringBoot 3.3.4: AI-Driven: JUnit 5, Mockito — Part 3', provides a concise explanation of Mockito's core concepts and annotations.
Read section '2. Mockito'. Focus on understanding the roles of @Mock, @InjectMocks, and @ExtendWith(MockitoExtension.class). This trio is the foundation of modern Mockito-based tests.
To summarize the key annotations:
@ExtendWith(MockitoExtension.class): Tells JUnit 5 to activate Mockito's features.@Mock: Creates a mock object for a dependency (e.g.,ProductRepository).@InjectMocks: Creates an instance of your SUT (e.g.,ProductService) and automatically injects any fields annotated with@Mockinto it.
Now, let's see this in action. We'll learn the two fundamental operations of mocking: stubbing behavior and verifying interactions.
Spring Boot Testing Tutorial - Part 1 | Unit Testing with JUnit 5 and Mockito
The 'Programming Techie' video demonstrates these concepts clearly. We'll see how to test a service that has dependencies on a repository and a mapper.
First, watch from 08:50 to 14:01. This section demonstrates how to mock dependencies and use when(...).thenReturn(...) to 'stub' their behavior, defining what they should return when called. Then, watch from 14:01 to 15:42 to see how the code is refactored to use the modern @Mock and @ExtendWith annotations, which is the standard practice you should follow.
Stubbing: Defining Mock Behavior
As you just saw, stubbing is how you tell your mock what to do. The syntax is highly readable:when(mockObject.someMethod(someArgument)).thenReturn(someResult);
This line of code means: "When someMethod is called on my mockObject with someArgument, don't execute the real logic; instead, just return someResult."
This gives you complete control over the test environment. You can simulate scenarios like:
- A repository finding an entity.
- A repository not finding an entity (by returning
Optional.empty()). - An external service call returning a specific DTO.
Verifying: Checking Mock Interactions
What if a method doesn't return anything? For instance, a save(Product product) or delete(Long id) method might return void. How do we test that it was called? We use verification.
Spring Boot Testing Tutorial - Part 1 | Unit Testing with JUnit 5 and Mockito
Let's continue with the video to learn about Mockito.verify().
Watch the section from 15:42 to 19:32. This part is crucial as it shows how to test a void method by verifying that the underlying repository.save() method was indeed called. It also introduces a powerful tool, ArgumentCaptor.
Verification is just as important as stubbing. It allows you to confirm that your SUT interacted with its dependencies as expected. The syntax is also very clear:verify(mockRepository, times(1)).save(any(Product.class));
This means: "Verify that the save method on my mockRepository was called exactly 1 time with any object of type Product."
3. Advanced Techniques for Production-Ready Tests
For senior-level interviews, you need to go beyond the basics. Knowing how to test edge cases and inspect interactions in detail will set you apart.
Capturing Arguments with ArgumentCaptor
The video introduced ArgumentCaptor. This is an extremely useful tool when you need to check the exact value that was passed to a mock's method. For example, when testing a create method, you might want to verify that before the entity was passed to repository.save(), certain fields were correctly set (e.g., createdAt timestamp, a status field). ArgumentCaptor lets you "capture" that object and perform detailed assertions on it.
Reducing Boilerplate with @BeforeEach
You may have noticed that setting up mocks and the SUT can be repetitive if you have multiple tests in the same class. JUnit's lifecycle annotations help keep your code DRY (Don't Repeat Yourself).
Spring Boot Testing Tutorial - Part 1 | Unit Testing with JUnit 5 and Mockito
Finally, let's look at a simple way to clean up our test classes.
Watch this short clip from 19:32 to 20:39. It shows how to use the @BeforeEach annotation to run setup code before every single test method, avoiding code duplication.
Using @BeforeEach for common setup is a standard best practice that makes your test suite much easier to maintain.
Test your understanding!
You have a NotificationService that sends an email when a new order is placed. It depends on a UserRepository to get the user's email and an EmailClient to send the notification.
// System Under Test (SUT)
public class NotificationService {
private final UserRepository userRepository;
private final EmailClient emailClient;
public NotificationService(UserRepository userRepository, EmailClient emailClient) {
this.userRepository = userRepository;
this.emailClient = emailClient;
}
public void sendOrderConfirmation(Long userId, String orderId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
String subject = "Your order " + orderId + " is confirmed!";
String body = "Hello " + user.getName() + ", thank you for your order.";
emailClient.sendEmail(user.getEmail(), subject, body);
}
}
// Dependencies (Interfaces)
public interface UserRepository {
Optional<User> findById(Long id);
}
public interface EmailClient {
void sendEmail(String to, String subject, String body);
}
- In your test class, which class will be the SUT and which will be mocked? How would you annotate them?
- How would you write the stub for the
userRepositoryto simulate a scenario where the user is found? - How would you verify that the
emailClient.sendEmail()method was called? - (Advanced) How could you use
ArgumentCaptorto verify that the email body sent was"Hello John Doe, thank you for your order."?
Show answer
-
The
NotificationServiceis the SUT and would be annotated with@InjectMocks. TheUserRepositoryandEmailClientare dependencies and would be annotated with@Mock. -
You would create a dummy
Userobject and stub thefindByIdmethod:User dummyUser = new User("John Doe", "john.doe@example.com"); when(userRepository.findById(1L)).thenReturn(Optional.of(dummyUser)); -
You would use
Mockito.verify():verify(emailClient, times(1)).sendEmail(anyString(), anyString(), anyString()); -
You would declare an
ArgumentCaptorfor the email bodyString, use it in theverifycall, and then assert its captured value.// At the class level or inside the test method ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class); // In the verification step verify(emailClient).sendEmail(anyString(), anyString(), bodyCaptor.capture()); // Assert the captured value String capturedBody = bodyCaptor.getValue(); assertThat(capturedBody).isEqualTo("Hello John Doe, thank you for your order.");
Conclusion
Congratulations on building a solid foundation in unit testing! This is the most frequent type of testing you'll write and is a critical part of a healthy development lifecycle.
Key Takeaways:
- Unit tests focus on a single component (the SUT) in isolation.
- JUnit 5 provides the testing framework (
@Test,@BeforeEach) and basic assertions. Fluent libraries like AssertJ are preferred for readability. - Mockito enables isolation by creating mocks (
@Mock) of dependencies, which are then injected into the SUT (@InjectMocks). - The two main mocking actions are stubbing (
when().thenReturn()) to define behavior and verifying (verify()) to check interactions. - For advanced scenarios, use
ArgumentCaptorto inspect the exact arguments passed to mocks andassertThrowsorassertThatThrownByto test exception paths.
Next Up
Unit tests are the base of the "Test Pyramid," but they can't catch all bugs. They don't verify that your component correctly integrates with external systems like a real database or a message broker. In our next lesson, we will move up the pyramid to address this. We will learn how to implement integration tests using Testcontainers to validate interactions with external dependencies like databases or message brokers. This will be a big step towards ensuring your microservice is truly production-ready.
Can't find a good explanation? Sign up and we'll make it for you
Sign up