Spring Boot HandBook

    Introduction :#

    Integration testing is a crucial phase in software development where individual modules or components of a system are tested together to ensure they work seamlessly as a whole. Unlike unit testing, which focuses on testing small units of code in isolation, integration testing aims to validate the interactions and interfaces between different components. This type of testing helps identify issues that might not be apparent during unit testing, such as problems with data flow, communication, or integration with external systems like databases or APIs. By simulating real-world scenarios, integration testing ensures that the components of a system function correctly when integrated, ultimately leading to a more reliable and robust software product.

    Real life analogy#

    Imagine you’re organizing a large dinner party where different people are responsible for different dishes: one person is making the main course, another is handling the appetizers, and yet another is preparing the dessert. Before the party, each dish might be tested separately to ensure it tastes good on its own.

    Integration testing in this scenario is like having a taste test of the entire meal to ensure that all the dishes work well together when served as a complete dinner. You check if the flavors complement each other, if the timing of serving each dish is right, and if everything is prepared correctly. If there’s a problem—like the dessert is too sweet and clashes with the main course—you’ll catch it during this combined taste test, ensuring that when the guests arrive, everything comes together perfectly.

    Importance of Integration Testing#

    Integration testing indeed plays a vital role in identifying issues that arise from the interactions between different components or services. By focusing on how these parts work together, integration tests help catch issues like:

    • Configuration Errors: Problems with how components are set up to interact with each other.
    • Missing Data: Situations where required data is not properly passed between components.
    • Incorrect Business Logic: Issues that arise when different components handle data or operations in unexpected ways.

    Moreover, integration tests are crucial for maintaining the stability of the system when changes are made. They help ensure that new updates or modifications don’t inadvertently break existing functionality, thus making regression testing more effective and safeguarding the overall integrity of the application.

    Example

    Think of integration testing like rehearsing a play with the entire cast before the actual performance. Each actor (module or component) has been trained individually and knows their lines and actions (unit testing).

    During rehearsal (integration testing), you bring everyone together to practice the full play (the entire system). This allows you to see how the actors interact on stage, if the scenes transition smoothly, and if the timing of cues is right.

    If an actor forgets their line or if the scene doesn’t flow well, it only becomes apparent when the full cast is involved. Similarly, integration testing reveals issues that might not be visible when each part of the system is tested in isolation, ensuring that everything works harmoniously when the "show" goes live.

    Testing The Presentation Layer#

    Testing the Presentation Layer in a Spring Boot application focuses on verifying the functionality of the controller or web layer (also known as the presentation layer). This layer handles incoming HTTP requests, interacts with the service layer, and sends appropriate responses back to the client. Testing the presentation layer ensures that the web endpoints (REST API or MVC controllers) are working as intended.

    Key Characteristics of This Test:#

    1. Full Application Context:
      • The test uses @SpringBootTest, which loads the full Spring application context, making it a true integration test across multiple layers (controller, service, and repository).
    2. Web Layer Testing:
      • It uses @AutoConfigureWebTestClient with a WebTestClient, which allows testing REST endpoints in a non-blocking manner, making HTTP requests to the web layer and verifying the HTTP responses.
      • timeout = "100000": Sets a custom timeout for requests made by WebTestClient. This is useful for ensuring that tests do not fail due to slow responses.
      • TestContainerConfiguration.class: This would be a custom configuration class that you’ve defined to set up things like test containers or other test-specific beans.
    3. Database Integration:
      • The test interacts with a real or in-memory database using the EmployeeRepository. The database is reset (employeeRepository.deleteAll()) in the @BeforeEach method to ensure that each test runs in isolation with a clean state.
    4. Different Scenarios Covered:
      • The test covers various scenarios such as:
        • Successful retrieval of an employee by ID.
        • Handling cases where an employee does not exist (404 Not Found).
        • Creating, updating, and deleting employees, including validations for invalid operations (e.g., updating email when it's restricted).

    Using WebTestClient#

    WebTestClient provides a fluent API and supports making real HTTP requests to your application. Although WebTestClient is primarily associated with WebFlux applications, it can also be used with Spring MVC applications when set up correctly. Use the @AutoConfigureWebTestClient to auto configure the WebTestClient.

    <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> <scope>test</scope> </dependency>

    WebTestClient Testing Methods#

    Here’s a brief explanation of the common WebTestClient response methods and how they’re used in testing:

    • exchange(): Executes the request and returns a WebTestClient.ResponseSpec. This method is often used to initiate the request and obtain the response for further assertions or processing.
    • expectStatus(): Asserts the status code of the response. For example, you can check if the response status is 200 OK or 404 Not Found, ensuring that the request was processed correctly.
    • expectBody(): Asserts the body of the response. This method allows you to verify the content of the response body, such as checking if it contains expected values or matches a specific format.
    • expectHeader(): Asserts the headers of the response. You can use this method to check if the response includes the correct headers and their values, ensuring that necessary metadata is correctly returned.
    • .jsonPath("$.id").isNotEmpty(): This method checks if a specific JSON path (e.g., $.id) in the response body is not empty. It ensures that the JSON object contains a value for the specified field.
    • .jsonPath("$.name").isEqualTo("Jane Doe"): This method asserts that the value of the JSON path $.name in the response body matches the expected value, in this case, "Jane Doe".
    • .jsonPath("$.email").isEqualTo("jane.doe@example.com"): Similar to the previous example, this method verifies that the value of the JSON path $.email in the response body is equal to "jane.doe@example.com".

    These methods are used in combination to validate different aspects of the response and ensure that the web application behaves as expected under test conditions.

    Test Cases for Controller Layer#

    • Controller Class

    Assume the following EmployeeController class that handles basic CRUD operations:

    import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping(path = "/employees") @RequiredArgsConstructor public class EmployeeController { private final EmployeeService employeeService; @GetMapping("/{id}") public ResponseEntity<EmployeeDto> getEmployeeById(@PathVariable Long id) { EmployeeDto employeeDto = employeeService.getEmployeeById(id); return ResponseEntity.ok(employeeDto); } @PostMapping public ResponseEntity<EmployeeDto> createNewEmployee(@RequestBody EmployeeDto employeeDto) { EmployeeDto createdEmployeeDto = employeeService.createNewEmployee(employeeDto); return new ResponseEntity<>(createdEmployeeDto, HttpStatus.CREATED); } @PutMapping("/{id}") public ResponseEntity<EmployeeDto> updateEmployee(@PathVariable Long id, @RequestBody EmployeeDto employeeDto) { EmployeeDto updatedEmployee = employeeService.updateEmployee(id, employeeDto); return ResponseEntity.ok(updatedEmployee); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteEmployee(@PathVariable Long id) { employeeService.deleteEmployee(id); return ResponseEntity.noContent().build(); } }

    Press CTRL + SHIFT + T on EmployeeController . And create a test class.

    • Service Class

    Here’s the business logics.

    import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.modelmapper.ModelMapper; import org.springframework.stereotype.Service; import java.util.List; @Service @RequiredArgsConstructor @Slf4j public class EmployeeService { private final EmployeeRepository employeeRepository; private final ModelMapper modelMapper; public EmployeeDto getEmployeeById(Long id) { log.info("Fetching employee with id: {}", id); EmployeeEntity employee = employeeRepository.findById(id) .orElseThrow(() -> { log.error("Employee not found with id: {}", id); return new ResourceNotFoundException("Employee not found with id: " + id); }); log.info("Successfully fetched employee with id: {}", id); return modelMapper.map(employee, EmployeeDto.class); } public EmployeeDto createNewEmployee(EmployeeDto employeeDto) { log.info("Creating new employee with email: {}", employeeDto.getEmail()); List<EmployeeEntity> existingEmployees = employeeRepository.findByEmail(employeeDto.getEmail()); if (!existingEmployees.isEmpty()) { log.error("Employee already exists with email: {}", employeeDto.getEmail()); throw new RuntimeException("Employee already exists with email: " + employeeDto.getEmail()); } EmployeeEntity newEmployee = modelMapper.map(employeeDto, EmployeeEntity.class); EmployeeEntity savedEmployee = employeeRepository.save(newEmployee); log.info("Successfully created new employee with id: {}", savedEmployee.getId()); return modelMapper.map(savedEmployee, EmployeeDto.class); } public EmployeeDto updateEmployee(Long id, EmployeeDto employeeDto) { log.info("Updating employee with id: {}", id); EmployeeEntity employee = employeeRepository.findById(id) .orElseThrow(() -> { log.error("Employee not found with id: {}", id); return new ResourceNotFoundException("Employee not found with id: " + id); }); if (!employee.getEmail().equals(employeeDto.getEmail())) { log.error("Attempted to update email for employee with id: {}", id); throw new RuntimeException("The email of the employee cannot be updated"); } modelMapper.map(employeeDto, employee); employee.setId(id); //7.6 EmployeeEntity savedEmployee = employeeRepository.save(employee); log.info("Successfully updated employee with id: {}", id); return modelMapper.map(savedEmployee, EmployeeDto.class); } public void deleteEmployee(Long id) { log.info("Deleting employee with id: {}", id); boolean exists = employeeRepository.existsById(id); if (!exists) { log.error("Employee not found with id: {}", id); throw new ResourceNotFoundException("Employee not found with id: " + id); } employeeRepository.deleteById(id); log.info("Successfully deleted employee with id: {}", id); } }
    • Custom Exception Class

    To handle all exceptions.

    import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException ex) { return ResponseEntity.notFound().build(); } @ExceptionHandler(RuntimeException.class) public ResponseEntity<?> handleRuntimeException(RuntimeException ex) { return ResponseEntity.internalServerError().build(); } }
    • Unit Test Cases for EmployeeController

    The primary class being tested is the EmployeeController, which handles HTTP requests (GET, POST, PUT, DELETE) and delegates the business logic to the service layer.

    import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.web.reactive.server.WebTestClient; @AutoConfigureWebTestClient(timeout = "100000") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Import(TestContainerConfiguration.class) class EmployeeControllerIntegrationTest { @Autowired private WebTestClient webTestClient; @Autowired private EmployeeRepository employeeRepository; private EmployeeEntity testEmployeeEntity; private EmployeeDto testEmployeeDto; private Long id = 1L; @BeforeEach public void setUp(){ testEmployeeEntity = EmployeeEntity .builder() .id(id) .name("ABC") .email("abc@gmail.com") .salary(10000.00) .build(); testEmployeeDto = EmployeeDto .builder() .id(id) .name("ABC") .email("abc@gmail.com") .salary(10000.00) .build(); employeeRepository.deleteAll(); } @Test public void testGetEmployeeById_Success(){ EmployeeEntity savedEmployee = employeeRepository.save(testEmployeeEntity); webTestClient .get() .uri("/employees/{id}",savedEmployee.getId()) .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.id").isEqualTo(savedEmployee.getId()) .jsonPath("$.email").isEqualTo(savedEmployee.getEmail()); } @Test public void testGetEmployeeById_Failure(){ webTestClient .get() .uri("/employees/{id}",id) .exchange() .expectStatus().isNotFound(); } @Test public void testCreateNewEmployee_WhenEmployeeAlreadyExist_ThenThrowException(){ EmployeeEntity employee = employeeRepository.save(testEmployeeEntity); webTestClient .post() .uri("/employees") .bodyValue(testEmployeeDto) .exchange() .expectStatus().is5xxServerError(); } @Test public void testCreateNewEmployee_WhenEmployeeDoseNotExist_ThenCreateNewEmployee(){ webTestClient .post() .uri("/employees") .bodyValue(testEmployeeDto) .exchange() .expectStatus().isCreated() .expectBody() .jsonPath("$.email").isEqualTo(testEmployeeDto.getEmail()) .jsonPath("$.name").isEqualTo(testEmployeeDto.getName()); } @Test public void testUpdateEmployee_WhenEmployeeDoesNotExist_ThenThrowException(){ webTestClient .put() .uri("/employees/{id}",id) .bodyValue(testEmployeeDto) .exchange() .expectStatus().isNotFound(); } @Test public void testUpdateEmployee_WhenAttemptingToUpdateEmail_ThenThrowException(){ EmployeeEntity savedEmployee = employeeRepository.save(testEmployeeEntity); testEmployeeDto.setEmail("xyz@gmail.com"); testEmployeeDto.setSalary(20000.00); webTestClient .put() .uri("/employees/{id}",savedEmployee.getId()) .bodyValue(testEmployeeDto) .exchange() .expectStatus().is5xxServerError(); } @Test void testUpdateEmployee_whenEmployeeIsValid_thenUpdateEmployee() { EmployeeEntity savedEmployee = employeeRepository.save(testEmployeeEntity); testEmployeeDto.setName("XYZ"); testEmployeeDto.setSalary(20000.00); webTestClient.put() .uri("/employees/{id}", savedEmployee.getId()) .bodyValue(testEmployeeDto) .exchange() .expectStatus().isOk() .expectBody(EmployeeDto.class) .isEqualTo(testEmployeeDto); } @Test void testDeleteEmployee_whenEmployeeDoesNotExists_thenThrowException() { webTestClient.delete() .uri("/employees/{id}",id) .exchange() .expectStatus().isNotFound(); } @Test void testDeleteEmployee_whenEmployeeExists_thenDeleteEmployee() { EmployeeEntity savedEmployee = employeeRepository.save(testEmployeeEntity); webTestClient.delete() .uri("/employees/{id}", savedEmployee.getId()) .exchange() .expectStatus().isNoContent() .expectBody(Void.class); webTestClient.delete() .uri("/employees/{id}", savedEmployee.getId()) .exchange() .expectStatus().isNotFound(); } }
    • Explanations

    This EmployeeControllerIntegrationTest class is a well-structured integration test suite for an employee management REST API using Spring Boot. The test suite covers various operations related to employees, such as creating, reading, updating, and deleting employee records. The primary class being tested is the EmployeeController, which handles HTTP requests (GET, POST, PUT, DELETE) and delegates the business logic to the service layer.

    Here's a breakdown of the main tests implemented:

    • Test Setup (@BeforeEach)
      • Each test starts by creating a sample EmployeeEntity and EmployeeDto instance.
      • Before each test, the employeeRepository.deleteAll() is called to ensure a clean database state, avoiding interference from previous tests.
    • Test Cases
      • Test for Successful Get Request
        • Get Employee by ID (Success):
          • Saves an employee to the database and verifies the successful retrieval via GET /employees/{id}.
          • Asserts the status code 200 OK and validates the returned employee data using jsonPath().
        • Get Employee by ID (Failure):
          • Tries to retrieve an employee with a non-existing ID and asserts that the status is 404 Not Found.
    • Test for Successful Post Request
      • Create New Employee (When Already Exists):
        • Saves an employee first and then tries to create the same employee again. This triggers an error and checks for a 500 Server Error.
      • Create New Employee (Success):
        • Attempts to create a new employee with valid data and asserts the employee was successfully created (201 Created) with matching data.
    • Test for Successful PUT Request
      • Update Employee (When Does Not Exist):
        • Tries to update a non-existing employee and verifies that the status returned is 404 Not Found.
      • Update Employee (When Email Changed):
        • Ensures that attempting to update an employee’s email results in a 500 Server Error.
      • Update Employee (When Valid):
        • Updates the employee’s name and salary and verifies that the updates are applied successfully (200 OK).
    • Test for Successful Delete Request
      • Delete Employee (When Does Not Exist):
        • Tries to delete an employee that doesn’t exist and asserts that the response status is 404 Not Found.
      • Delete Employee (Success):
        • Deletes an existing employee and checks that it’s removed correctly (204 No Content), followed by verifying that the same employee cannot be deleted again (404 Not Found).
    • Outputs
      • Output of Successful Get Request
        Output of Get Employee by ID (Success):

    Output of Get Employee by ID (Failure):

    • Output of Successful Post Request
      Output of Create New Employee (When Already Exists)

    Output of Create New Employee (Success):

    • Output of Successful PUT Request
      Output of Update Employee (When Does Not Exist):

    Output of Update Employee (When Email Changed):

    Output of Update Employee (When Valid):

    • Output of Successful Delete Request
      Delete Employee (When Does Not Exist):

    Delete Employee (Success):

    Here, we have already covered these aspects, which are highlighted in green in the image.

     

    Last updated on Dec 09, 2024