Unit testing the persistence layer involves verifying that your data access logic (like repositories or DAOs) works correctly. Here's a brief introduction:
- Setup: Use an in-memory database (e.g., H2) or mocks to isolate the persistence layer from other parts of the application. This ensures tests are fast and reliable.
- Test Cases: Write tests for CRUD operations (Create, Read, Update, Delete) to verify that data is correctly saved, retrieved, updated, and deleted. Also, test any custom queries or logic specific to your persistence layer.
- Assertions: Check that the results from your repository methods match the expected outcomes, such as verifying that an entity is saved and retrieved correctly or that a query returns the expected results.
- Isolation: Ensure tests do not depend on each other or the actual database state. Use setup and teardown methods to create and clean up test data.
We need a basic Web MVC code. We have already seen how to write a boilerplate Web MVC code.
Here’s that all needed classes and files.
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();
}
}
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);
}
}
Here we use lot of ‘log’ for debugging purpose.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EmployeeRepository extends JpaRepository<EmployeeEntity, Long> {
List<EmployeeEntity> findByEmail(String email);
}
We don’t have to all those method like get by id, save, delete methods these methods are provided by JpaRepository. We have to write test methods for those method which are created by us only. Basically, we should check if they are working as expected or not. If you create some query method or you are creating some JPQL methods that you are not sure if they are working as expected or not then you can write separate test cases for that. For custom repository methods like findByEmail()
, writing test cases ensures that these methods perform as expected. Since basic CRUD operations provided by JpaRepository
don't need explicit testing, the focus is on testing custom query methods.
Here, we create a test case for ‘findByEmail()’ method. Let’s check how to write test cases.
- Steps to Create a Test Case for
findByEmail()
:- Create Test File for Repository:
- Navigate to the
EmployeeRepository
interface. - Use the shortcut
Ctrl + Shift + T
(Windows) or Command + Shift + T
(Mac) to open the "Create New Test" window.
- Click on ‘Create New Test’
- Select the necessary options for the test file setup, such as test annotations like
@Before
, @After
, and the members to include. - After selecting "members only" while creating the test, the test class file will be generated inside your test directory (typically
src/test/java/com/example/project/repository
). This file will contain the structure for testing the specific repository method, such as findByEmail()
.
- Write the Test Case:
- Here we create two methods for understanding purpose and after that we have to implement these two methods. Here you can use
@SpringBootTest
. For writing our implementation we have to have (EmployeeRepository
) the repository. Because we are testing this employee repository. For this here we have to inject the repository.
Here you can see that after running the test file, the first method took 651 ms, while the second method took 5 ms. This happened because the first method was used to spin up the application context, and once that was done, the second method reused the cached application context.
import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MapperConfig {
@Bean
public ModelMapper getMapper(){
return new ModelMapper();
}
}
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class EmployeeDto {
private Long id;
private String name;
private String email;
private Double salary;
}
import jakarta.persistence.*;
import lombok.*;
@Builder
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "employees")
public class EmployeeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(unique = true)
private String email;
private Double salary;
}
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {super((message));}
public ResourceNotFoundException(String message, Throwable causes) {
super(message,causes);
}
}
spring.datasource.url=jdbc:postgresql://localhost:5432/<Database name>?useSSL=false
spring.datasource.username=<Your user name>
spring.datasource.password=<Your password>
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
- XML File (For Dependencies)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
//used for testing purpose only(inmemory database)
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
//used for development purpose (developement database)
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>
When we ran the test file, we can see that, since we used @SpringBootTest, the entire Spring Boot application was started. If you scroll down, you can see that it used the PostgreSQL database instead of the in-memory database (H2).
If you want to configure the test database, you can use @AutoConfigureTestDatabase.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
class EmployeeRepositoryTest {
@Autowired
private EmployeeRepository employeeRepository;
@Test
//Test 'findByEmail()' when Email is valid then return Employee
void testFindByEmail_whenEmailIsValid_thenReturnEmployee() {
}
@Test
//Test 'findByEmail()' when Email is not found then return empty Employee List
void testFindByEmail_whenEmailIsNotFound_thenReturnEmptyEmployeeList() {
}
}
After running this test file.
Here we can see that it actually uses the embedded database, which is the H2 database. Now all are test cases will be run on the H2 database only.
The @AutoConfigureTestDatabase
annotation is used to control the configuration of the database used in tests. It helps specify whether and how the test database should replace or use the existing DataSource configuration. Here’s a brief overview:
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
:- Purpose: This setting tells Spring Boot not to replace the existing DataSource with a test-specific one. This is useful when you want to use a custom DataSource configuration or a real database rather than an in-memory database for your tests.
- Use Case: Ideal for scenarios where you have a specific database configuration that you want to test against, or if you’re integrating with a pre-configured DataSource.
- Options for
replace
:AutoConfigureTestDatabase.Replace.ANY
: Replaces any existing DataSource bean, whether it’s auto-configured by Spring Boot or manually defined. This is the default behavior if replace
is not explicitly set.AutoConfigureTestDatabase.Replace.NONE
: Ensures that the existing DataSource configuration is not replaced by a test-specific DataSource. This maintains your application’s default DataSource setup.AutoConfigureTestDatabase.Replace.AUTO_CONFIGURED
: Replaces the DataSource only if it was auto-configured by Spring Boot. This allows you to use a test database while retaining custom DataSource configurations.
- Read more AutoConfigureTestDatabase (Spring Boot 3.3.3 API)
@DataJpaTest
is a specialized test annotation in Spring Boot designed for testing JPA components. Here's a quick summary of its key features:
- In-Memory Database: It automatically configures an in-memory database (like H2) for the test, so you don’t need to worry about configuring a real database.
- Spring Data JPA Setup: It sets up Spring Data JPA repositories and scans for JPA entities, ensuring that your repository methods are tested in an environment similar to your production setup.
- Transactional Tests: By default, each test method is run within a transaction that is rolled back after the test completes. This ensures that each test starts with a clean slate, preventing tests from affecting each other and providing a reliable testing environment.
- Isolation: It helps ensure that tests are isolated from each other, as the transaction rollback will revert any changes made during the test, maintaining the integrity of your test data.
This setup is particularly useful for unit testing repository methods, validating JPA queries, and ensuring that your data access layer behaves as expected.
Implementations Two Methods
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.orm.jpa.DataJpaTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class EmployeeRepositoryTest {
@Autowired
private EmployeeRepository employeeRepository;
private EmployeeEntity employee;
//Create a employee
@BeforeEach
void setup(){
employee = EmployeeEntity
.builder()
.id(1L)
.name("Alic")
.email("alice@gmail.com")
.salary(100000.00).build();
}
@Test
//Test 'findByEmail()' when Email is valid then return Employee
void testFindByEmail_whenEmailIsValid_thenReturnEmployee() {
//Arrange, Given
employeeRepository.save(employee);
//Act, When
List<EmployeeEntity> employeeEntityList = employeeRepository.findByEmail(employee.getEmail());
//Assert, Then
assertThat(employeeEntityList).isNotNull();
assertThat(employeeEntityList).isNotEmpty();
assertThat(employeeEntityList.get(0).getEmail()).isEqualTo(employee.getEmail());
}
@Test
//Test 'findByEmail()' when Email is not found then return empty Employee List
void testFindByEmail_whenEmailIsNotFound_thenReturnEmptyEmployeeList() {
//Arrange, Given
String email = "bob@gmail.com";
//Act, When
List<EmployeeEntity> employeeEntityList =employeeRepository.findByEmail(employee.getEmail());
//Assert, Then
assertThat(employeeEntityList).isNotNull();
assertThat(employeeEntityList).isEmpty();
}
}
Output
We can use @DataJpaTest instead of @SpringBootTest . And you can also remove the @AutoConfigureTestDatabase part.
Difference Between @DataJpaTest
and @SpringBootTest
@DataJpaTest
and @SpringBootTest
are both used for testing in Spring Boot but serve different purposes. Here’s a comparison of their differences:
- Scope and Purpose:
@DataJpaTest
:- Tailored specifically for testing JPA components, like repositories.
- It sets up an in-memory database, scans for JPA entities, and configures Spring Data JPA repositories.
- Ideal for testing the persistence layer (queries, custom repository methods) without loading the entire application context.
@SpringBootTest
:- Used for full integration testing of the entire Spring application context.
- Loads the entire application context, including all the beans and configurations.
- Suitable for testing the interaction between multiple layers (controller, service, repository, etc.).
- Loaded Context:
@DataJpaTest
:- Only loads the beans related to the JPA layer (like repositories and entities).
- Does not load other layers like controllers, services, or security configurations.
@SpringBootTest
:- Loads the full application context, including all beans, services, controllers, security, etc.
- Provides a complete environment to test the application as a whole.
- Performance:
@DataJpaTest
:- Faster, as it loads only the necessary components for JPA testing.
- Uses an in-memory database (like H2) by default, which is quick to set up and tear down.
@SpringBootTest
:- Slower, as it loads the entire application context, which takes more time to initialize.
- Useful for end-to-end or integration tests where you need the full application environment.
- Transactional Behavior:
@DataJpaTest
:- By default, tests are transactional, and transactions are rolled back after each test. This ensures a clean state for each test.
@SpringBootTest
:- Not transactional by default, but transactions can be enabled for specific tests.
- Use Cases:
@DataJpaTest
:- Testing repositories, custom queries, and JPA entity mappings.
@SpringBootTest
:- Full application testing, including the interaction between controllers, services, repositories, and other components.
- Code:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class EmployeeRepositoryTest {
@Autowired
private EmployeeRepository employeeRepository;
@Test
//Test 'findByEmail()' when Email is valid then return Employee
void testFindByEmail_whenEmailIsValid_thenReturnEmployee() {
employeeRepository.findByEmail("");
}
@Test
//Test 'findByEmail()' when Email is not found then return empty Employee List
void testFindByEmail_whenEmailIsNotFound_thenReturnEmptyEmployeeList() {
}
}
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class EmployeeRepositoryTest {
@Autowired
private EmployeeRepository employeeRepository;
@Test
//Test 'findByEmail()' when Email is valid then return Employee
void testFindByEmail_whenEmailIsValid_thenReturnEmployee() {
employeeRepository.findByEmail("");
}
@Test
//Test 'findByEmail()' when Email is not found then return empty Employee List
void testFindByEmail_whenEmailIsNotFound_thenReturnEmptyEmployeeList() {
}
}
- Output: