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.
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:
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%.
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.
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%.
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.
Test class run button
After that, go to the 'Coverage' tab and click it.
Coverage tab
Here, we see.
Coverages
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.
Employee service class
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@Slf4jpublic 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 onlyclass 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.
This article focused on unit testing the service layer in a Spring Boot application, emphasizing the use of mocks and test coverage for various scenarios. It demonstrated how to test CRUD operations using tools like Mockito, JUnit, and ModelMapper. Ultimately, unit testing ensures reliable code but requires quality test cases, not just high coverage.