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,
Testing applications that follow a three-tier architecture typically involves a multi-faceted approach:
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:
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:
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?
@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:
@Mock and @MockBean ?
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:
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:
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.
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:
@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 ?
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.
Full Stack Web Developer
6 个月Very helpful
Attended Sabaragamuwa University of Sri Lanka
6 个月Interesting???
Software Engineer Intern | Bachelor of Science in Computing & Information Systems | MERN Stack | Next JS | Spring Boot
6 个月Insightful
Undergraduate | Bsc. Hons in Computing and Information Systems
6 个月Very informative!??