Mastering Testing in Spring Boot: Essentials for Unit and Integration Tests

Testing is crucial in building reliable software. It helps you catch issues early, ensures your application works as expected, and gives you confidence as you make changes. In this guide, we’ll explore two fundamental types of testing in a Spring Boot app: unit tests and integration tests. We will also walk through the essentials, including libraries and annotations, to help you succeed.

Essential Libraries for Spring Boot Testing

Spring Boot testing is powered by several key libraries that work together to make testing easier and more efficient:

  • JUnit: The core testing framework. It provides the basic testing functionality, including the @Test annotation and assertion methods.
  • Spring Boot Test: Provides annotations like @SpringBootTest to load the full application context, enabling integration tests with Spring’s features.
  • Mockito: A powerful framework for creating mock objects to simulate dependencies, used primarily in unit tests to isolate logic.

Spring Boot’s spring-boot-starter-test dependency includes these libraries, along with other testing tools like Hamcrest for better assertions and MockMvc for testing web layers.

Must-Know Annotations for Testing

Annotations are essential for configuring and running tests in Spring Boot. Below are the most commonly used ones, grouped by the framework they belong to:

  • JUnit
    • @Test: Marks a method as a test method.
  • Spring
    • @Autowired: Injects Spring-managed beans into your test class.
  • Mockito
    • @Mock: Creates a mock object that simulates a dependency in your unit tests. Think of a mock object as a fake version of something your class needs, like a pretend database, so you can test your code without the real thing.
    • @InjectMocks: Injects the mock objects into the class under test in your unit tests.
    • @ExtendWith(MockitoExtension.class): Integrates Mockito with JUnit 5, enabling mock creation and injection.
  • Spring Boot Test
    • @SpringBootTest: Loads the full application context for integration tests, enabling you to test your application in a real Spring environment.
    • @MockitoBean: Creates a mock bean within the Spring context, useful for replacing dependencies in integration tests.

Setting Up the Application

Before diving into both unit and integration tests, let’s first build the basic Spring Boot application that we will be testing. This simple application allows us to manage users, including retrieving them by ID and adding new users.

1. Spring Boot Application Entry Point

Start with the main entry point for running the Spring Boot application. This is the class that triggers the application to run.

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2. Entity: User

The @Entity annotation marks the class as a JPA entity, meaning it maps to a database table, with each object of the class representing a row in the table. The @Id annotation marks the id field as the primary key, and @GeneratedValue(strategy = GenerationType.IDENTITY) ensures the database automatically generates the ID. The @Table annotation is used to specify the table name, and if not present, the name in @Entity will be used. The no-args constructor is needed for converting JSON from the web into Java objects.

@Entity(name = "CUSTOM_USER")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    // No-args constructor for deserialization
    public User() { }

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    // Getters and setters (omitted for simplicity)
}

3. Repository: UserRepository

Next, define the repository for the User entity. This interface extends JpaRepository, providing CRUD (Create, Read, Update, Delete) functionality without needing to implement any methods manually.

public interface UserRepository extends JpaRepository<User, Long> {

}

4. Service: UserService

The service class contains the business logic for managing users. It interacts with the UserRepository to fetch and save user data.

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUserById(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    public User saveUser(User user) {
        return userRepository.save(user);
    }
}

5. Controller: UserController

The controller is responsible for handling HTTP requests. It exposes endpoints for retrieving a user by ID and adding a new user.

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        User user = userService.getUserById(id);
        if (user != null) {
            return ResponseEntity.ok(user);
        } else {
            return ResponseEntity.notFound().build();
        }
    }

    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User user) {
        User savedUser = userService.saveUser(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedUser);
    }
}

6. Maven Dependencies

Here are the dependencies you’ll need to run this Spring Boot application, including Spring Web, Spring Data JPA, and H2 for an in-memory database.

...

<dependencies>
    <!-- Spring Boot Starter for Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Starter for Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- H2 Database (for testing purposes) -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Spring Boot Starter Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

...

7. Application Configuration: application.yml

Now, configure the application using application.yml. This file sets up the H2 database and enables the H2 console for testing purposes.

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true

Understanding Unit Tests

Unit tests focus on testing individual methods in isolation, without relying on other parts of the application. To isolate the method, we use mocks to replace any dependencies, like databases or external services. Unit tests are fast, focused, and help you identify issues quickly. Think of them like checking a single part of a machine, ensuring this individual part functions correctly before assembling the whole thing. They validate a method’s behavior under specific conditions, making sure it performs as expected.

Unit Test Structure: Three Simple Parts

  1. Assumptions – Set up inputs and mock dependencies, specifying how they should behave in the test scenario.
  2. Execution – Call the method you’re testing.
  3. Validations – Check if the result is what you expected.

Example of a Simple Unit Test

Let’s test the getUserById() method from the UserService class above. Here’s a simple unit test to check if method that fetches a user by their ID works correctly:

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void testGetUserById() {
        // Assumptions
        User mockUser = new User(1L, "John Doe");
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        // Execution
        User result = userService.getUserById(1L);

        // Validations
        assertEquals("John Doe", result.getName());
    }

    @Test
    void testGetUserByIdNotFound() {
        // Assumptions
        when(userRepository.findById(1L)).thenReturn(Optional.empty());

        // Execution
        User result = userService.getUserById(1L);

        // Validations
        assertNull(result);
    }
}

In this test, we cover two scenarios: the “happy flow,” where everything works as expected, and a negative case, where the user is not found. In both cases, we mock the UserRepository to isolate the getUserById() method. Using when(...).thenReturn(...), we simulate different behaviors of the repository, such as returning a mock user or no user at all. After calling the method, we validate the results to ensure that the UserService behaves as expected in each scenario.

Understanding Integration Tests

Integration tests check how different parts of your application work together. They make sure that components, which may work fine individually and in their own unit tests, function well when combined. These tests are important for verifying things like how services interact with repositories or how controllers handle requests. Integration tests follow the same structure as unit tests: Assumptions, Execution, and Validations, but they focus on testing real interactions between components like controllers, services, and repositories.

Let’s go over two common integration tests you’ll write in Spring Boot: Controller-Service and Service-Repository.

1. Controller-Service Integration Test

The first common integration test focuses on testing the interaction between a Controller and a Service. This is usually done to verify that the controller correctly handles HTTP requests and delegates business logic to the service layer.

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void testGetUserById() throws Exception {
        User mockUser = new User(1L, "John Doe");
        when(userService.getUserById(1L)).thenReturn(mockUser);

        mockMvc.perform(get("/users/1")) // Simulates a real HTTP request
              .andExpect(status().isOk())
              .andExpect(jsonPath("$.name").value("John Doe"));
    }
}

In this test, we’re verifying that the UserController correctly interacts with the UserService. The MockMvc object allows us to simulate HTTP requests, while the @AutoConfigureMockMvc annotation sets up the testing environment for those requests. Similar to a unit test, we mock the UserService to isolate its behavior, but here we include Spring dependency injection to test the real controller and validate the HTTP request handling.

2. Service-Repository Integration Test

Another typical integration test checks the interaction between a Service and a Repository. Here, we want to ensure that the service correctly communicates with the database through the repository.

@SpringBootTest
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Test
    void testGetUserByIdFromRepository() {
        // Create a new user instance and set the name
        User mockUser = new User();
        mockUser.setName("John Doe");

        // Save the user to the database (ID is auto-generated)
        userRepository.save(mockUser);

        // Fetch the user by ID using the service method
        User result = userService.getUserById(1L);

        // Assert that the fetched user's name matches the expected value
        assertEquals("John Doe", result.getName());
    }
}

In this test, we focus on ensuring that the UserService is interacting correctly with the UserRepository. The @SpringBootTest annotation loads the entire Spring context, including the database, so that we can test the actual persistence layer with real components. Anytime we use @Autowired in a @SpringBootTest, we are getting a real instance of a component. We save a user to the H2 in-memory database that was configured earlier and then use the service to retrieve it, validating that the retrieved user’s name matches the expected value.

Conclusion

Spring Boot testing provides powerful tools to ensure your application works as expected. By leveraging JUnit, Spring Boot Test, and Mockito for advanced configurations, you can write effective and manageable tests. With the key annotations and scenarios covered, you’re ready to implement unit and integration tests that help maintain high-quality, reliable code in your Spring Boot applications.