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.
- After that, go to the 'Coverage' tab and click it.
- 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
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);
}
}
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.