Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 93

Testing with JUnit, Mockito, and Testcontainers

Recommended resources

Hello! Welcome to the next lesson in our "CI/CD & System Design Interview Practice" module.

In our last lesson, we discussed zero-downtime deployment strategies like Rolling Updates and Blue-Green. A core theme was that confidence in your deployment strategy is directly proportional to your confidence in the code itself. Today, we'll build that confidence from the ground up by focusing on automated testing.

This lesson directly addresses the learning outcome: Implement unit and integration tests using JUnit, Mockito, and Testcontainers. Mastering these tools is non-negotiable for a senior engineer. It demonstrates a commitment to quality, maintainability, and the ability to build robust, enterprise-scale applications—qualities highly valued in the FAANG-level and fintech interviews you're preparing for.

We will cover three layers of testing:

  1. Unit Testing: Verifying individual components in complete isolation.
  2. Integration Testing: Ensuring different parts of your Spring application work together as intended.
  3. High-Fidelity Integration Testing: Using Testcontainers to test against real services, like your production database, running in Docker.

1. Unit Testing: The Foundation of Quality

Unit tests are the bedrock of a solid testing strategy. They are fast, focused, and verify that a single class or method behaves correctly without relying on external systems like databases or network calls. To achieve this isolation, we use "mocks."

For this, the standard toolkit in the Java ecosystem is JUnit 5 (the testing framework) and Mockito (the mocking library).

Key Concepts & Tools

  • JUnit 5: Provides the test runner and assertion library (e.g., assertEquals). Key annotations include @Test, @BeforeEach, @AfterEach.
  • Mockito: Allows you to create "mock" objects that simulate the behavior of real dependencies. This lets you test a class in isolation. Key annotations and methods include:
    • @Mock: Creates a mock implementation for a class or interface.
    • @InjectMocks: Creates an instance of the class you want to test and injects the mocks (created with @Mock) into it.
    • when(...).thenReturn(...): Defines the behavior of a mock object's method.
    • verify(...): Checks if a mock object's method was called with specific arguments.

Spring Boot Testing: A Comprehensive Best Practices Guide

To see these concepts in action, let's study a practical example. The article 'Spring Boot Testing: A Comprehensive Best Practices Guide' by Ankit Kumar provides an excellent walkthrough of unit testing a UserService that depends on a UserRepository.

Read the section titled 'Unit Testing'. Focus on understanding the difference between setting up mocks manually versus using annotations like @Mock and @InjectMocks. Pay close attention to the examples for ArgumentCaptor and BDDMockito—these are powerful tools that can come up in technical discussions.

The Arrange-Act-Assert Pattern

As you saw in the article, a clean unit test follows the Arrange-Act-Assert pattern (also known as Given-When-Then in BDD style):

  1. Arrange (Given): Set up the test. This includes creating mock objects, defining their behavior with when(), and preparing any input data.
  2. Act (When): Execute the method you are testing.
  3. Assert (Then): Verify the outcome. This involves checking the return value of the method (assertEquals) and/or verifying that your code interacted correctly with its dependencies (verify).

Let's solidify this with an example based on the article's UserService.

@ExtendWith(MockitoExtension.class) // Enables Mockito annotations
class UserServiceTest {

    @Mock // Create a mock UserRepository
    private UserRepository userRepository;

    @InjectMocks // Create a UserService and inject the mock userRepository
    private UserService userService;

    @Test
    void shouldReturnUserNameWhenUserExists() {
        // Arrange
        User user = new User(1L, "Alice");
        // Define mock behavior: when findById(1L) is called, return our user
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        // Act
        String userName = userService.getUserName(1L);

        // Assert
        assertEquals("Alice", userName); // Check the result
        verify(userRepository).findById(1L); // Verify the repository was called
    }

    @Test
    void shouldThrowExceptionWhenUserNotFound() {
        // Arrange
        // Define mock behavior: when findById is called with any Long, return empty
        when(userRepository.findById(anyLong())).thenReturn(Optional.empty());

        // Act & Assert
        // Check that calling the method throws the expected exception
        assertThrows(RuntimeException.class, () -> {
            userService.getUserName(99L);
        });
    }
}
Test your understanding!

You have a NotificationService that sends an email using an EmailClient. The sendEmail method in the EmailClient returns void. How would you write a unit test for your NotificationService to verify that when you call notifyUser(user), the emailClient.sendEmail() method is called with the correct user's email address? Which Mockito feature is essential here?

Show answer

You would use ArgumentCaptor to capture the argument passed to the sendEmail method, since you can't check a return value.

  1. Arrange:

    • Mock the EmailClient.
    • Create an ArgumentCaptor for the String class (the email address).
    • The sendEmail method is void, so you don't need a when(...).thenReturn(...) stub unless you need to test for exceptions (doThrow).
  2. Act:

    • Call notificationService.notifyUser(testUser).
  3. Assert:

    • Use verify(emailClient).sendEmail(captor.capture()) to run the method and capture the argument.
    • Use assertEquals(testUser.getEmail(), captor.getValue()) to assert that the captured email address is correct.

This demonstrates your ability to test not just the outcome of a method but also its interactions (side effects), which is a crucial skill.


2. Integration Testing: Verifying Collaboration

While unit tests are essential, they can't tell you if your components work together correctly within the Spring framework. For example:

  • Does your @RestController correctly deserialize JSON and call your @Service?
  • Does your @Repository correctly execute a query against a database?

This is where integration tests come in. They load a part of, or all of, your Spring application context to test the interaction between components.

Test Slices vs. Full Context

Spring Boot offers two main approaches:

  1. Test Slices (@WebMvcTest, @DataJpaTest): These are lightweight integration tests that load only a specific "slice" of the application context. For example, @WebMvcTest loads the web layer (controllers, filters, etc.) but mocks the service layer. This is faster and more focused.
  2. Full Context (@SpringBootTest): This loads the entire application context, just like running the real application. It's slower but provides the highest confidence that everything is wired correctly.

Spring Boot Testing: A Comprehensive Best Practices Guide

The same guide provides clear examples for these slice tests. It's important to understand when to use a focused slice versus a full context test.

Now, read the 'Component Testing' section. Pay close attention to the @WebMvcTest example, noting the use of MockMvc to simulate HTTP requests and @MockBean to provide mock dependencies for the web layer. Also, review the @DataJpaTest example, which shows how to test the repository layer, typically against an in-memory H2 database.


3. High-Fidelity Integration Testing with Testcontainers

Using an in-memory database like H2 for @DataJpaTest is fast, but it has a significant drawback: H2 is not PostgreSQL (or MySQL, or Oracle). Subtle differences in SQL dialects, transaction handling, or available functions can lead to tests that pass locally but fail in production.

Testcontainers solves this problem by bringing the power of Docker to your tests. It allows you to programmatically start, use, and stop lightweight, disposable Docker containers for any service you need—databases, message brokers, caches, etc.

This means you can run your integration tests against a real PostgreSQL database, giving you extremely high confidence that your persistence layer will work as expected in a production environment.

Unit and Integration Testing with Testcontainers in Spring Boot
This diagram shows how unit tests focus on individual components in isolation, while integration tests verify their interactions. Testcontainers elevates integration testing by replacing in-memory fakes with real services (like PostgreSQL) running in Docker, ensuring your tests more closely mirror your production environment.

There are two primary ways to integrate Testcontainers with Spring Boot:

  1. @DynamicPropertySource: The traditional way. You declare a container, and then write a static method to dynamically get the JDBC URL, username, and password from the running container and set them as Spring properties.
  2. @ServiceConnection (Spring Boot 3.1+): A modern, cleaner approach. You simply add this annotation to your container declaration, and Spring Boot automatically configures the connection for you. Using this shows you are up-to-date with the latest best practices.

Spring Boot Testing: A Comprehensive Best Practices Guide

Let's see how to implement this. The 'Spring Boot Testing' guide has a great example using @DynamicPropertySource. We'll supplement this with the official Spring Boot documentation to understand the newer @ServiceConnection feature.

First, read the subsection 'Testing with TestContainers (Database Integration)' in the guide to understand the problem Testcontainers solves and see the implementation with @DynamicPropertySource.

Now, let's look at the official documentation to see the different integration styles, including the cleaner @ServiceConnection.

Testcontainers :: Spring Boot

The official Spring Boot documentation provides the definitive guide on integrating Testcontainers.

Please review the following sections: 'Using the JUnit Extension' (shows the @Testcontainers and @Container annotations), 'Service Connections' (explains the modern @ServiceConnection annotation), and 'Dynamic Properties' (shows the @DynamicPropertySource method you saw in the previous article). Comparing sections 5 and 6 will make the benefits of @ServiceConnection very clear.

Here's a complete example using @SpringBootTest and the modern @ServiceConnection approach, which is the preferred method for new applications:

// You need the spring-boot-testcontainers and testcontainers-postgresql dependencies
// in your build.gradle or pom.xml

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Testcontainers // Activates Testcontainers support for JUnit 5
class UserRepositoryIntegrationTest {

    @Container // Manages the lifecycle of the container
    @ServiceConnection // Automatically configures the datasource properties
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    
    @Autowired
    private UserRepository userRepository;

    @Test
    void connectionEstablished() {
        // A simple test to confirm the container started and is reachable
        assertTrue(postgres.isCreated());
        assertTrue(postgres.isRunning());
    }

    @Test
    void shouldSaveAndFindUserByEmail() {
        // Arrange
        User user = new User(null, "test@example.com", "Test User");
        
        // Act
        userRepository.save(user);
        
        // Assert
        Optional<User> foundUser = userRepository.findByEmail("test@example.com");
        
        assertTrue(foundUser.isPresent());
        assertEquals("Test User", foundUser.get().getName());
    }
}

Conclusion

You have now learned the fundamentals of building a multi-layered testing strategy for a Spring Boot application. This is a critical skill set that separates junior developers from senior engineers who are trusted to build and deploy reliable software.

Key Takeaways:

  • Unit Tests (JUnit & Mockito): Use them to test business logic in isolation. They are fast and form the base of your testing pyramid. ArgumentCaptor is a key tool for verifying interactions with void methods.
  • Integration Tests (@SpringBootTest, Slices): Use them to verify that your Spring components are correctly configured and interact with each other as expected. Test slices like @WebMvcTest offer a faster, more focused alternative.
  • Testcontainers: Elevate your integration tests by using real services (like PostgreSQL, Kafka, Redis) in Docker containers. This eliminates the gap between your test and production environments. Prefer the modern @ServiceConnection annotation where possible.

Preview of the Next Lesson:
We've covered testing the service and data layers. Next, we'll zoom in on the API layer. In the next lesson, "Implement REST API tests using MockMvc or REST Assured," we will dive deeper into writing comprehensive and maintainable tests for your REST controllers, ensuring your API contracts are stable and reliable.