Introducing Testing Strategies for Spring Boot Applications.

Introducing Testing Strategies for Spring Boot Applications.

Spring MVC / REST / WebFlux Reactive applications typically follow a three-tier (or three-layer) architecture. This architecture divides applications into three interconnected layers,

Three-tier architecture

Testing applications that follow a three-tier architecture typically involves a multi-faceted approach:

  1. Unit Testing: This involves testing each layer in isolation to ensure that individual components such as controllers, services, and repositories function correctly on their own.
  2. Integration Testing: This stage tests the interaction between multiple layers to verify that they work together seamlessly.
  3. API Testing with Test Contracts: This involves evaluating the application as a whole to ensure that it adheres to specified requirements and behaves as expected from an end-user perspective.

Unit Testing vs Integration Testing Concept

Unit Testing: In the context of Spring, this means testing the behavior of a specific class outside of the Spring application context, ensuring that no other components or dependencies are loaded during the testing process. The goal of unit testing is to verify that each unit of the application performs as expected independently from the rest of the system.

Integration Testing: In Spring Boot, integration testing typically requires loading the full application context, including all necessary beans and dependencies. This approach ensures that the functionality of various classes or layers is tested in conjunction with one another, verifying that they interact properly and that the application behaves as expected in a real-world scenario.


Unit Testing in Spring Boot

To perform isolated tests in a Spring Boot application, certain dependencies are required to provide the tools and frameworks necessary for testing. Common dependencies includes:

  1. JUnit: The core framework for writing and running tests in java (org.junit.jupiter).
  2. Mockito: A popular mocking framework (org.mockito)
  3. Spring Boot Starter Test: Testing starter dependency that includes JUnit, Mockito, Spring Test, AssertJ, Hamcrest and other commonly used libraries all in one. (org.springframework.boot)


Unit Testing Service Layer

Testing the service layer also can present challenges, particularly when the class under test, relies on other service classes or repository interfaces. To address this, the Mockito framework is employed to create mock objects for these dependencies. These mocks are then injected into the service class, allowing for isolated and effective testing of the service logic.

Consider below simple service method.

public final CustomerRepo customerRepo;
private final CustomerMapper customerMapper;

public void updateCustomer(CustomerRequest request) {
    var customer = repo.findById(request.Id()).orElseThrow(()-> new CustomerNotFoundException(
            String.format("cannot update customer %s", request.Id())
    ));
    mergerCustomer(customer, request);
    repo.save(customer);
}        

In this method, there are two external dependencies (customerRepo, customerMapper) and also a nested method call (mergeCustomer(args, args)).

In order to unit test this method, these dependencies need to be provided without actually instantiating them. Typically a mock object of each type is injected into the service class using the Mockito framework.

To handle nested method calls, a Spy is used to retain the original behavior of mergeCustomer method while allowing to control and verify interactions as needed.

public class CustomerServiceTest {

    @Mock
    private CustomerRepo customerRepo; 

    @Mock
    private CustomerMapper customerMapper;

    @InjectMocks
    private CustomerService customerService; 

    @Spy
    private CustomerService spyCustomerService; 

    @BeforeEach
    public void setup() {
        spyCustomerService = spy(customerService);
    }

    @Test
    public void testUpdateCustomer() {
        // Given
        CustomerRequest request = new CustomerRequest(
                null, "firstName","lastName","Email" ); 

        Customer OldCustomer = new Customer(1,"Id","OldFirstName","OldLastName"."Email");

      Customer Updated = new Customer
(1, "Id","NewFirstName","NewLastName","Email" );

        // Mock repository behavior
        when(customerRepo.findById(1)).thenReturn(Optional.of(existingCustomer));

        // Spy on mergeCustomer to avoid calling the actual method
        doNothing().when(spyCustomerService).mergeCustomer(existingCustomer, request);

        // When
        spyCustomerService.updateCustomer(request);

        // Then
        verify(customerRepo).findById(1); 
        verify(customerRepo).save(existingCustomer);
        verify(spyCustomerService).mergeCustomer(existingCustomer, request);
    }        

Explanation:

  • Mocks and Spies: CustomerRepo and CustomerMapper Objects are mocked and injected to CustomerService.
  • Test Setup: test initializes a spy on CustomerService to allow spying nested method calls.
  • Test Execution: mocks the behavior of CustomerRepo.findById(1) method to return an existing customer, Spy to override the mergeCustomer method to avoid calling the actual method. And then Calls updateCustomer on the spyCustomerService.
  • Assertions: Verifies that customerRepo.findById(1) , customerRepo.save(existingCustomer), and mergeCustomer(existingCustomer, request) methods are called correctly during the execution.


Unit Testing Controller Layer

Controller classes typically depend on service classes (the service layer), which means these dependencies need to be mocked to properly isolate the controller's logic during unit testing.

@RestController
@RequestMapping("/customers")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerService service;

    @PostMapping
    public ResponseEntity<String> createCustomer(
            @RequestBody @Valid CustomerRequest request
    ) {
        return ResponseEntity.ok(service.createCustomer(request));
    }
}        

To accurately test a controller, it is crucial to simulate an HTTP context. Without this, the test would not fully mimic the behavior of a real Spring MVC application. The @WebMvcTest annotation is specifically designed for this purpose.

Why Use @WebMvcTest?

  • Minimal Context Loading: It only loads the Spring components required to test the web layer, not the entire application context.
  • Allows Mocking of Dependencies: Under this annotation, dependencies like services can still be mocked using tools like Mockito.
  • Isolated HTTP Layer Testing: It primarily focuses on testing the controller's interaction with the HTTP layer, ensuring that requests are correctly processed and responses are correctly generated, without the need for a full application context.

@WebMvcTest(CustomerController.class)
class CustomerControllerUnitTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    CustomerService customerService;

    @Test
    void shouldCreateCustomer() throws Exception {

        String response = "Created";

        when(customerService.createCustomer(any(CustomerRequest.class)))
                .thenReturn(response);

        mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/customers")
                        .contentType(MediaType.APPLICATION_JSON)
                                .content("{\"firstname\": \"value1\", \"lastname\": \"value2\", \"email\": \"[email protected]\", \"address\": {\"street\": \"123 Main St\", \"houseNumber\": \"A1\", \"zipCode\": \"12345\"}}")
                        ).andExpect(MockMvcResultMatchers.status().isOk());
    }
}        

Explanation:

  • Setup: MockMvc is injected to simulate HTTP requests, and @MockBean is used to mock the CustomerService dependency.
  • Mocking Behavior: The when...thenReturn construct mocks the createCustomer method of CustomerService to return "Created" when any CustomerRequest is passed.
  • Performing the Test: MockMvc performs a simulated HTTP POST request to the `/customers` endpoint with JSON content.
  • Verification: The test asserts that the response status is 200 OK, verifying that the controller correctly handles the HTTP request.


@Mock and @MockBean ?

  • @Mock: Creates a mock instance of a class or interface (Object) using Mockito to mock dependencies without requiring any Spring context which allows test methods in isolation.
  • @MockBean: Creates a mock of a Spring bean and adds it to the application context, specifically used in Spring Boot tests (like those annotated with @WebMvcTest) to replace real beans with mocked versions.


Unit testing Persistence layer ?

For the persistence layer, when using standard ORM frameworks like Hibernate or JPA, JPQL or Criteria API queries are automatically validated by the ORM and do not need to be explicitly tested.

However, if the application is using native SQL queries which are not validated by the ORM may contain errors.

Non-Native ( JPQL Query ): Hibernate translate these queries into SQL with validation.

@Query("SELECT e FROM Employee e WHERE e.department = :department")
List<Employee> findByDepartment(@Param("department") String department);          

Native SQL query: Row SQL queries that directly executed against the database.

@Query(value = "SELECT * FROM employees WHERE department = :department", nativeQuery = true)
List<Employee> findByDepartmentNative(@Param("department") String department);        

For this kind of query, the typical approach is to write integration tests.


Integration Testing in Spring Boot

To perform integration testing, certain dependencies are essential:

  1. Spring Boot Test: provides utilities and annotations like @SpringBootTest, @AutoConfigureMockMvc, and @TestConfiguration, enabling a more integrated and contextual test environment.
  2. TestContainers: A library that helps manage Docker containers for integration tests, ensuring that tests run in isolated and reproducible environments (org.testcontainers).


Integration Testing the Persistence Layer

To test native queries, the @DataJpaTest annotation is used, which configures a lightweight, in-memory database environment optimized for JPA-based repository testing.

@DataJpaTest
public class EmployeeRepositoryIntegrationTest {

    @Autowired
    private EmployeeRepository employeeRepository;

    @Test
    void testFindByDepartmentNative() {
        // Given
        Employee employee1 = new Employee("John Doe", "IT");
        Employee employee2 = new Employee("Jane Doe", "IT");
        Employee employee3 = new Employee("Alice Smith", "HR");

        employeeRepository.save(employee1);
        employeeRepository.save(employee2);
        employeeRepository.save(employee3);

        // When
        List<Employee> itEmployees = employeeRepository.findByDepartmentNative("IT");

        // Then
        assertThat(itEmployees).isNotNull();
        assertThat(itEmployees.size()).isEqualTo(2);
        assertThat(itEmployees).extracting(Employee::getName).containsExactlyInAnyOrder("John Doe", "Jane Doe");
    }        

With @DataJpaTest, all database operations within the scope of a test method are executed within a transaction that is automatically rolled back after the test completes. This default rollback behavior ensures that the database state is not modified permanently by the tests, allowing for repeatable and isolated test runs.

Explanation:

  1. @DataJpaTest: Configures an in-memory database and sets up the JPA repository layer for testing purposes, providing a lightweight environment focused on testing JPA components.
  2. Test Setup: The test method testFindByDepartmentNative creates three Employee objects and saves them to the EmployeeRepository.
  3. Repository Method Invocation: The method findByDepartmentNative is called to fetch employees belonging to the "IT" department.
  4. Assertions: The test verifies that the retrieved list is not null, contains exactly two employees, and specifically includes "John Doe" and "Jane Doe", confirming that the native query functions as expected.

For more comprehensive integration testing, the @SpringBootTest annotation can be used in conjunction with the @Transactional annotation to load the entire Spring application context. The @Transactional annotation ensures that any changes made to the database during the test are rolled back after the test completes, maintaining database consistency.

@SpringBootTest
@Transactional
public class EmployeeRepositoryIntegrationTest {

    @Autowired
    private EmployeeRepository employeeRepository;

    @Test
    void testFindByDepartmentNative() {
        // Given
        Employee employee1 = new Employee("John Doe", "IT");
        Employee employee2 = new Employee("Jane Doe", "IT");
        Employee employee3 = new Employee("Alice Smith", "HR");

        employeeRepository.save(employee1);
        employeeRepository.save(employee2);
        employeeRepository.save(employee3);

        // When
        List<Employee> itEmployees = employeeRepository.findByDepartmentNative("IT");

        // Then
        assertThat(itEmployees).isNotNull();
        assertThat(itEmployees.size()).isEqualTo(2);
        assertThat(itEmployees).extracting(Employee::getName).containsExactlyInAnyOrder("John Doe", "Jane Doe");
    }
}        

Using @SpringBootTest with @Transactional is suitable for tests that require the full application context to be loaded, ensuring that all components are properly initialized and tested in an integrated environment.


Integration Testing Controller Layer and Service Layer

Controller Layer

For integration testing the controller layer, using @MockMvcTest is often sufficient. This approach focuses specifically on testing the behavior of the controller in handling HTTP requests and responses.

  • Isolate Controller Logic: Ensures that the controller's HTTP mappings, request handling, and response generation work correctly without loading the entire Spring application context.
  • Improve Test Efficiency: Provides faster and more focused tests by avoiding the complexities associated with a full application context, making it ideal for validating controller-specific functionality.

Service Layer

For integration testing the service layer, it is preferable to use @Autowired to inject real dependencies rather than using @MockBean or @Mock with @InjectMocks. This approach allows:

  • Test Real Interactions: Verify that the service layer interacts correctly with actual implementations of its dependencies, such as repositories, within the full application context.
  • Ensure End-to-End Functionality: Validate that the service logic integrates correctly with other components and handles data as expected..

@SpringBootTest
public class EmployeeServiceIntegrationTest {

    @Autowired
    private EmployeeService employeeService;

    @Autowired
    private EmployeeRepository employeeRepository;

    @Test
    void testGetEmployeesByDepartment() {
        // Given
        Employee employee1 = new Employee("John Doe", "IT");
        Employee employee2 = new Employee("Jane Doe", "IT");
        Employee employee3 = new Employee("Alice Smith", "HR");

        employeeRepository.save(employee1);
        employeeRepository.save(employee2);
        employeeRepository.save(employee3);

        // When
        List<Employee> itEmployees = employeeService.getEmployeesByDepartment("IT");

        // Then
        assertThat(itEmployees).isNotNull();
        assertThat(itEmployees.size()).isEqualTo(2);
        assertThat(itEmployees).extracting(Employee::getName).containsExactlyInAnyOrder("John Doe", "Jane Doe");
    }
}        

@Autowired vs. @MockBean ?

  • @Autowired: Use @Autowired in integration tests to test how the service interacts with real implementations of its dependencies. This is particularly useful for verifying end-to-end functionality within the application context.
  • @MockBean: Use @MockBean to isolate the service layer from its dependencies and focus on testing the service logic independently. This is useful for unit tests or when you want to control and simulate the behavior of dependencies.


Conclusion

Effective testing is crucial for developing reliable Spring MVC, REST, or WebFlux applications, which often follow a three-tier architecture. A comprehensive testing strategy combines unit testing, integration testing, and API testing to ensure all layers controllers, services, and repositories function correctly in isolation and when integrated.

Unit testing isolates individual components, like service methods or controllers, using tools like Mockito with @Mock and @MockBean, while @WebMvcTest focuses on testing controllers in a simulated HTTP environment. Integration testing ensures that multiple layers work together seamlessly using annotations like @SpringBootTest and @DataJpaTest to load the Spring application context, verifying real-world interactions.

Combining these approaches ensures robust, well-functioning applications ready for production.


Savindu Rashmika

Full Stack Web Developer

6 个月

Very helpful

回复
Venura Pavan

Attended Sabaragamuwa University of Sri Lanka

6 个月

Interesting???

Srishan Mandawala

Software Engineer Intern | Bachelor of Science in Computing & Information Systems | MERN Stack | Next JS | Spring Boot

6 个月

Insightful

Githmi Hashara

Undergraduate | Bsc. Hons in Computing and Information Systems

6 个月

Very informative!??

要查看或添加评论,请登录

Pawan Hettiarachchi的更多文章

社区洞察

其他会员也浏览了