Spring Boot HandBook

    Introduction :#

    Unit testing the service layer in a Spring application involves verifying the business logic encapsulated in service classes. Here are some key points to consider:

    • Testing Business Logic: Focus on the core logic of service methods.
    • Using Mocks: Mock dependencies like repositories or other services to isolate the service being tested.
    • Covering Scenarios: Include tests for normal operation, edge cases, and exception handling.
    • Assertions: Verify outcomes with assertions to ensure the service behaves as expected.
    • Test Configuration: Use annotations like @Mock and @InjectMocks to set up tests and inject mocks.

    Coverage#

    In the context of unit testing, coverage refers to how much of your code is exercised by your tests. It measures the extent to which your test cases cover the lines, branches, or methods of your codebase.

    Here are the main types of code coverage:

    1. Line Coverage: Measures the percentage of code lines executed by your tests. For example, if you have 100 lines of code and your tests execute 80 of them, your line coverage is 80%.
    2. Branch Coverage: Measures the percentage of decision branches (like if statements) that are executed. This ensures that every possible branch in decision-making code is tested.
    3. Method Coverage: Measures the percentage of methods that are called by your tests. If your code has 10 methods and tests call 8 of them, your method coverage is 80%.
    4. Path Coverage: Measures the percentage of possible execution paths through your code that are tested. This is more comprehensive but also more complex to achieve, as it considers all possible paths through your code.

    High coverage indicates that a significant portion of your code is tested, which generally increases confidence in the quality and reliability of the code. However, 100% coverage doesn't guarantee that your code is bug-free, so it's important to also consider the quality and relevance of your test cases.

    • Go to your test file and click the 'Run' button for the test class.

    • After that, go to the 'Coverage' tab and click it.

    • Here, we see.

    • Line Coverage: How many lines of code are executed.
    • Branch Coverage: How many branches (decision points) are executed.
    • Method Coverage: How many methods are called.
    • Path Coverage: How many execution paths are tested.

    Each type of coverage provides insights into different aspects of your testing and helps ensure that your code is thoroughly tested from various angles. Here, branches are not covering all the if and else conditions, which is why it's not showing 100% coverage. Essentially, we haven't used any if and else statements at all.

    We can see here the line number where the red part of this code (the exception part) is not implemented.

    Let's implement everything for all the methods.

    Implement

    • Service Class
    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); 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); } }
    • Service Test Class
    import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.modelmapper.ModelMapper; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.context.annotation.Import; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @Import(TestContainerConfiguration.class) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) //to use docker container only class EmployeeServiceTest { @Mock private EmployeeRepository employeeRepository; //to create mock @Spy private ModelMapper modelMapper; @InjectMocks private EmployeeService employeeService; private EmployeeEntity mockEmployeeEntity; private EmployeeDto mockEmployeeDto; private Long id = 1L; //Create an employee @BeforeEach void setup(){ mockEmployeeEntity = EmployeeEntity .builder() .id(id) .name("Alic") .email("alice@gmail.com") .salary(100000.00) .build(); mockEmployeeDto = modelMapper.map(mockEmployeeEntity,EmployeeDto.class); } // Test getEmployeeById() when EmployeeId is present then return EmployeeDto @Test public void testGetEmployeeById_WhenEmployeeIdIsPresent_ThenReturnEmployeeDto(){ //assign when(employeeRepository.findById(id)).thenReturn(Optional.of(mockEmployeeEntity)); // to stub mock //act EmployeeDto employeeDto = employeeService.getEmployeeById(1L); //assert assertThat(employeeDto).isNotNull(); assertThat(employeeDto.getId()).isEqualTo(mockEmployeeEntity.getId()); assertThat(employeeDto.getEmail()).isEqualTo(mockEmployeeEntity.getEmail()); verify(employeeRepository,only()).findById(id); //to verify mock } // Test getEmployeeById() when EmployeeId is not present then throw exception @Test public void testGetEmployeeById_WhenEmployeeIdIsNotPresent_ThenThrowException(){ //assign when(employeeRepository.findById(anyLong())).thenReturn(Optional.empty()); //act and assert assertThatThrownBy(() -> employeeService.getEmployeeById(1L)) .isInstanceOf(ResourceNotFoundException.class) .hasMessage("Employee not found with id: 1"); verify(employeeRepository).findById(1L); } // Test createNewEmployee() when employee valid then create new employee @Test public void testCreateNewEmployee_WhenValidEmployee_ThenCreateNewEmployee(){ //arrange when(employeeRepository.findByEmail(anyString())).thenReturn(List.of()); when(employeeRepository.save(any(EmployeeEntity.class))).thenReturn(mockEmployeeEntity); //act EmployeeDto employeeDto = employeeService.createNewEmployee(mockEmployeeDto); //to stub mock //assert assertThat(employeeDto).isNotNull(); assertThat(employeeDto.getEmail()).isEqualTo(mockEmployeeDto.getEmail()); ArgumentCaptor<EmployeeEntity> employeeEntityArgumentCaptor = ArgumentCaptor.forClass(EmployeeEntity.class); //argument captor verify(employeeRepository).save(employeeEntityArgumentCaptor.capture()); //to verify mock EmployeeEntity captorEmployeeEntity = employeeEntityArgumentCaptor.getValue(); assertThat(captorEmployeeEntity.getEmail()).isEqualTo(mockEmployeeEntity.getEmail()); } // Test createNewEmployee() when attempting to create employee with existing email then throw exception @Test public void testCreateNewEmployee_whenAttemptingToCreateEmployeeWithExistingEmail_thenThrowException() { //arrange when(employeeRepository.findByEmail(mockEmployeeDto.getEmail())).thenReturn(List.of(mockEmployeeEntity)); //act and assert assertThatThrownBy(()-> employeeService.createNewEmployee(mockEmployeeDto)) .isInstanceOf(RuntimeException.class) .hasMessage("Employee already exists with email: "+mockEmployeeEntity.getEmail()); verify(employeeRepository).findByEmail(mockEmployeeDto.getEmail()); verify(employeeRepository,never()).save(any()); } // Test update employee when employee does not exist then throw exception @Test void testUpdateEmployee_whenEmployeeDoesNotExists_thenThrowException() { //arrange when(employeeRepository.findById(1L)).thenReturn(Optional.empty()); //act and assert assertThatThrownBy(() -> employeeService.updateEmployee(1L, mockEmployeeDto)) .isInstanceOf(ResourceNotFoundException.class) .hasMessage("Employee not found with id: 1"); verify(employeeRepository).findById(1L); verify(employeeRepository, never()).save(any()); } // Test update employee when attempting to update email then throw exception @Test void testUpdateEmployee_whenAttemptingToUpdateEmail_thenThrowException() { //7.6 //arrange when(employeeRepository.findById(1L)).thenReturn(Optional.of(mockEmployeeEntity)); mockEmployeeDto.setName("Random"); mockEmployeeDto.setEmail("random@gmail.com"); //act and assert assertThatThrownBy(() -> employeeService.updateEmployee(mockEmployeeDto.getId(), mockEmployeeDto)) .isInstanceOf(RuntimeException.class) .hasMessage("The email of the employee cannot be updated"); verify(employeeRepository).findById(mockEmployeeDto.getId()); verify(employeeRepository, never()).save(any()); } // Test update employee when valid employee then update employee @Test public void testUpdateEmployee_whenValidEmployee_thenUpdateEmployee(){ //7.6 // arrange when(employeeRepository.findById(mockEmployeeDto.getId())).thenReturn(Optional.of(mockEmployeeEntity)); mockEmployeeDto.setName("Random name"); mockEmployeeDto.setSalary(299000.00); EmployeeEntity newEmployee = modelMapper.map(mockEmployeeDto, EmployeeEntity.class); when(employeeRepository.save(any(EmployeeEntity.class))).thenReturn(newEmployee); //act EmployeeDto updatedEmployeeDto = employeeService.updateEmployee(mockEmployeeDto.getId(), mockEmployeeDto); //assert assertThat(updatedEmployeeDto).isEqualTo(mockEmployeeDto); //for this we have to add hashCode and equals method in our dto class verify(employeeRepository).findById(1L); verify(employeeRepository).save(any()); } //Test delete employee when employee does not exist then throw exception @Test void testDeleteEmployee_whenEmployeeDoesNotExists_thenThrowException() { //7.6 //arrange when(employeeRepository.existsById(1L)).thenReturn(false); //act and assert assertThatThrownBy(() -> employeeService.deleteEmployee(1L)) .isInstanceOf(ResourceNotFoundException.class) .hasMessage("Employee not found with id: " + 1L); verify(employeeRepository, never()).deleteById(anyLong()); } // Test delete employee when employee @Test void testDeleteEmployee_whenEmployeeIsValid_thenDeleteEmployee() { //7.6 // arrange when(employeeRepository.existsById(1L)).thenReturn(true); //act and assert assertThatCode(() -> employeeService.deleteEmployee(1L)) .doesNotThrowAnyException(); verify(employeeRepository).deleteById(1L); } }

    Explanation

    This test class EmployeeServiceTest is written to unit test the EmployeeService class in a Spring Boot application. It uses JUnit 5 for testing and Mockito to mock and spy dependencies. Let’s break it down step by step:

    • Annotations Used:
      • @ExtendWith(MockitoExtension.class): This annotation integrates Mockito with JUnit 5 to enable the use of mocks and spies in test cases.
      • @Import(TestContainerConfiguration.class): This annotation imports additional configurations needed for running the test, like using Testcontainers.
      • @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE): Ensures that the test uses the real database in Docker (Testcontainers) instead of an in-memory database.
    • Mocks and Spies:
      • @Mock: EmployeeRepository employeeRepository is mocked, meaning that during the tests, an actual EmployeeRepository is not used. Instead, its behavior is simulated using Mockito.
      • @Spy: ModelMapper modelMapper is a spy, which allows the real object to be used but certain methods can be stubbed to simulate specific behavior.
      • @InjectMocks: EmployeeService employeeService is the class being tested, with mocks injected automatically.
    • Test Data:
      • mockEmployeeEntity: This represents a mock EmployeeEntity object used to simulate employee records.
      • mockEmployeeDto: This is the DTO (EmployeeDto) that corresponds to the mockEmployeeEntity object.
    • Setup Method
      • @BeforeEach void setup(): Initializes mock data before each test. mockEmployeeEntity is an instance of EmployeeEntity, and mockEmployeeDto is a EmployeeDto mapped from the entity.
    • Unit Tests Explained:
      • Test for Successful Get Request
        • testGetEmployeeById_WhenEmployeeIdIsPresent_ThenReturnEmployeeDto():
          • Purpose: Test that getEmployeeById() returns an EmployeeDto when the employee exists.
          • Setup: Mocks findById to return the mockEmployeeEntity.
          • Assertions: Verifies that the returned DTO is not null and matches the expected values. Ensures findById was called once.
        • testGetEmployeeById_WhenEmployeeIdIsNotPresent_ThenThrowException():
          • Purpose: Test that getEmployeeById() throws an exception when the employee does not exist.
          • Setup: Mocks findById to return an empty Optional.
          • Assertions: Verifies that the appropriate exception is thrown and that findById was called.
      • Test for Successful Post Request
        • testCreateNewEmployee_WhenValidEmployee_ThenCreateNewEmployee():
          • Purpose: Test that createNewEmployee() successfully creates a new employee when the input is valid.
          • Setup: Mocks findByEmail to return an empty list and save to return the mockEmployeeEntity.
          • Assertions: Verifies that the returned DTO matches the input and that save was called with the correct parameters.
        • testCreateNewEmployee_whenAttemptingToCreateEmployeeWithExistingEmail_thenThrowException():
          • Purpose: Test that createNewEmployee() throws an exception if an employee with the same email already exists.
          • Setup: Mocks findByEmail to return a list containing the mockEmployeeEntity.
          • Assertions: Verifies that the appropriate exception is thrown and save was not called.
      • Test for Successful Put Request
        • testUpdateEmployee_whenEmployeeDoesNotExists_thenThrowException():
          • Purpose: Test that updateEmployee() throws an exception if the employee to update does not exist.
          • Setup: Mocks findById to return an empty Optional.
          • Assertions: Verifies that the appropriate exception is thrown and save was not called.
        • testUpdateEmployee_whenAttemptingToUpdateEmail_thenThrowException():
          • Purpose: Test that updateEmployee() throws an exception if attempting to change the employee's email.
          • Setup: Mocks findById to return the mockEmployeeEntity and modifies the DTO to have a new email.
          • Assertions: Verifies that the appropriate exception is thrown and save was not called.
        • testUpdateEmployee_whenValidEmployee_thenUpdateEmployee():
          • Purpose: Test that updateEmployee() successfully updates an employee when provided with valid data.
          • Setup: Mocks findById to return the mockEmployeeEntity, updates the DTO, and mocks save to return the updated entity.
          • Assertions: Verifies that the returned DTO matches the updated input and that save was called.
      • Test for Successful Delete Request
        • testDeleteEmployee_whenEmployeeDoesNotExists_thenThrowException():
          • Purpose: Test that deleteEmployee() throws an exception if the employee to delete does not exist.
          • Setup: Mocks existsById to return false.
          • Assertions: Verifies that the appropriate exception is thrown and deleteById was not called.
        • testDeleteEmployee_whenEmployeeIsValid_thenDeleteEmployee():
          • Purpose: Test that deleteEmployee() successfully deletes an employee when it exists.
          • Setup: Mocks existsById to return true.
          • Assertions: Verifies that no exception is thrown and that deleteById was called.
    • Output

    After running the test file.

    Now, if we check the coverage, we can see that it is 100%.

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

    Last updated on Dec 09, 2024