Getting started with unit testing in spring boot

Easy practical guide to testing repository, service and controller layers with JUnit and Mockito.

Dharshi Balasubramaniyam
Javarevisited
16 min readMay 26, 2024

--

1. What and Why Unit testing?

1.1. Importance of Testing in Software development

Testing in Software development cycle

Testing is one of the most critical aspects of software development process, which ensures that the software functions correctly, meets the specified requirements, and provides a positive user experience.

  • Detecting and fixing bugs early in the development process is much cheaper and easier than addressing them later.
  • Testing confirms that the software meets the specified requirements and functions correctly. This helps ensure that the final product aligns with the expectations and needs of stakeholders and users.
  • Thorough testing helps identify and address usability issues, ensuring a smooth and intuitive user experience.
  • Testing can identify security vulnerabilities and weaknesses in the software. This helps protect against potential threats and ensures that the software is secure against attacks.
  • Testing helps ensure that the software complies with industry standards, regulations, and best practices. This is important for legal and regulatory compliance, particularly in industries like healthcare, finance, and aerospace.

1.2. Test pyramid

Test Pyramid

The Test Pyramid is a concept that guides developers in creating a balanced and efficient testing strategy. It divides tests into three main categories:

  1. Unit Tests (Base of the Pyramid): Test individual components or functions in isolation.
  2. Integration Tests (Middle Layer): Test the interaction between different components or systems.
  3. End-to-End Tests (Top of the Pyramid): Test the entire application flow, from start to finish.

1.3. Importance of unit Testing in test pyramid

Imagine you’re building a car. Unit testing is like testing each part of the car — such as the engine, brakes, and lights — separately to ensure they work properly before assembling the entire vehicle. This way, you can catch and fix problems early, making sure each part functions correctly on its own.

Unit testing
  • Unit tests form the foundation of the Test Pyramid.
  • They are the most numerous because they are quick to execute and provide immediate feedback on code quality. This layer ensures that the building blocks of the application are solid.
  • By having a larger number of unit tests, which are quick and inexpensive to run, developers can get immediate feedback on their code changes. This helps in identifying and fixing issues early in the development process.
  • Unit tests are cheaper to write and maintain compared to integration and E2E tests. Focusing on unit tests reduces the overall cost of testing while still providing a high level of code coverage.
  • The pyramid structure helps manage risks by catching most bugs at the unit level. This reduces the likelihood of defects making it to production, ensuring higher software quality.
  • While unit tests cover individual components, integration tests ensure that these components work together, and E2E tests validate the entire application. This layered approach ensures thorough testing coverage across different levels of the application.
  • Unit tests also serve as documentation, providing clear examples of how the code is supposed to work and helping new developers understand the system.

In Spring Boot, unit testing is especially valuable because it allows you to test your application’s components (like controllers, services, and repositories) independently from the rest of the system.

Let’s explore how to write unit tests in Spring Boot. This guide will provide you with practical insights and examples. Unit testing is essential for ensuring the reliability and quality of your application and mastering it can significantly enhance your development process. So, let’s dive in and see how we can effectively test our Spring Boot applications!

2. Setting up a Spring Boot Project for Unit Testing

Before effectively write unit tests in Spring Boot, we have to set up our project correctly.

Step 1: Creating a New Spring Boot Project

  • Go to the Spring Initializr. And create a project as below. Extract the downloaded project and open it in your IDE

Step 2: Adding dependencies

  • Open pom.xml (for Maven) and ensure the following dependencies are included:
<dependencies>
<!-- Spring Boot Starter for building web applications, including RESTful applications -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Project Lombok for reducing boilerplate code in Java classes -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- JUnit Jupiter Engine for writing and executing JUnit 5 tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>

<!-- Spring Boot Starter Test for testing Spring Boot applications -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- MySQL Connector/J for MySQL database connectivity -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>

<!-- Spring Boot Starter Validation for data validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Spring Boot Starter Data JPA for JPA-based data access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- H2 Database for in-memory testing of databases -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

Step 3: Create the folder structure.

  • Below is the project structure, I am going to use throughout the article.

Please kindly note that within the test package, I’ve taken the liberty of organizing separate packages for controller tests, service tests, and repository tests.

Let’s now proceed to create a simple user example. In this demonstration, I’ll be focusing solely on the scenario of registering a user and exploring various test cases associated with this process. Before delving into the unit tests for this scenario, let’s begin by writing the code.

Step 4: Writing code for user registration

Please kindly go through the comments for the explanation of the code.

4.1. User Model

// Annotates the class as a JPA entity, allowing it to be mapped to a database table
@Entity
// Lombok annotation to generate getters, setters, equals, hashCode, and toString methods
@Data
// Lombok annotation to generate a no-args constructor
@NoArgsConstructor
// Lombok annotation to generate an all-args constructor
@AllArgsConstructor
// Lombok annotation to generate a builder pattern for creating instances
@Builder
// Specifies the name of the database table to which this entity is mapped
@Table(name = "users")
public class User {

// Marks the field as the primary key
@Id
// Specifies the generation strategy for the primary key values
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;

// Represents the name of the user
private String name;

// Represents the email address of the user
private String email;
}

4.2. User Request DTO

@Data
@Builder
public class UserDto {

// Validation annotations (@Email, @NotNull, @Size) ensure that the
// provided email and name meet the required criteria.

@Email(message = "Email is not in a valid format!")
@NotBlank(message = "Email is required!")
private String email;

@NotBlank(message = "Name is required!")
@Size(min = 3, message = "Name must have at least 3 characters!")
@Size(max = 20, message = "Name can have at most 20 characters!")
private String name;
}

4.3. User Repository

// Spring Data JPA annotation indicating that this interface is a repository component
@Repository
public interface UserRepository extends JpaRepository<User, Integer> {

// Method signature to find a user by email address
User findByEmail(String email);

}

4.4. User Service Interface

// Interface defining the contract for user-related business logic
public interface UserService {

// Method signature for registering a new user
ResponseEntity<Object> registerUser(UserDto userDto);

}

4.5. User Service Implementation

// Spring annotation to indicate that this class is a Spring-managed component
@Component
// Lombok annotation to automatically generate a logger instance named "log"
@Slf4j
public class UserServiceImpl implements UserService {

// Autowired annotation to inject the UserRepository dependency
@Autowired
private UserRepository userRepository;

// Method implementation for registering a new user
@Override
public ResponseEntity<Object> registerUser(UserDto userDto){

try {
// Check if a user with the provided email already exists
if (userRepository.findByEmail(userDto.getEmail()) == null) {
// If not, create a new user entity with the provided data
User newUser = User.builder().name(userDto.getName()).email(userDto.getEmail()).build();
// Save the new user entity to the database
userRepository.save(newUser);

// Return a success response
return ResponseEntity
.status(HttpStatus.CREATED)
.body("Success: User details successfully saved!");
}

} catch (Exception e) {
// Log any exceptions that occur during user registration
log.error("User registration fail: " + e.getMessage());
// Return an internal server error response
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Fail: Failed to process request now. Try again later");
}

// If a user with the provided email already exists, return a conflict response
return ResponseEntity.status(HttpStatus.CONFLICT).body("Fail: Email already exists!");

}

}

4.6. Rest Exception Handler

// Spring annotation to indicate that this class handles exceptions in RESTful controllers
@RestControllerAdvice
public class RestExceptionHandler {

// Exception handler method for handling MethodArgumentNotValidException
// Thrown when the required critera for name and email in user DTO
// is not presented in the request recieved from frontend/postman API
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity<?> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException exception) {

// Map to store field errors and their corresponding error messages
Map<String, String> errors = new HashMap<>();

// Iterate through all validation errors
exception.getBindingResult().getAllErrors().forEach(error -> {
// Extract the field name and error message
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
// Add the field error to the map
errors.put(fieldName, errorMessage);
});

// Return a response with status code 400 (Bad Request) and the error details
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}

4.7. User Controller

// Spring annotation to indicate that this class is a REST controller
@RestController
// Base mapping for all request mappings in this controller
@RequestMapping("/user")
public class UserController {

// Autowired annotation to inject the UserService dependency
@Autowired
private UserService userService;

// Endpoint to handle POST requests for registering a new user
@PostMapping("/new")
// Annotation to validate the request body before calling register user method
public ResponseEntity<?> registerUser(@Valid @RequestBody UserDto userDto) {
// Delegate the registration logic to the UserService
return userService.registerUser(userDto);
}

}

3. Testing spring boot components

Writing Unit Tests with JUnit and Mockito

In this section, we’ll explore how to write unit tests using JUnit and Mockito, two widely-used testing frameworks in the Java ecosystem.

JUnit is a popular Java framework for writing unit tests. It provides annotations and assertions to facilitate the writing and execution of tests.

Mockito is a mocking framework that allows you to mock dependencies and interactions between objects in your tests. It helps isolate the code under test and makes it easier to focus on specific units of behavior.

Testing Repository

  1. Configuring Test database.
  • Use an in-memory database like H2 for testing. Spring Boot will auto-configure it for you.
  • It helps each test starts with a clean database state, ensuring no residual data from previous tests can affect the results.
  • In-memory databases like H2 are lightweight and optimized for fast operations, leading to quicker test execution compared to traditional databases.
  • There is no need to start an external database server, which reduces the setup and teardown time for tests.
  • Ensure you have the H2 database dependency in your pom.xml. I have already added that dependency in step 2.2.

2. Use @DataJpaTest.

  • Annotate your test class with @DataJpaTest. This annotation configures the minimal configuration required to test JPA applications.
  • @DataJpaTest will automatically configure an in-memory database, scan for @Entity classes, and configure Spring Data JPA repositories.

3. Identify Testcases.

  • Test case 1: Test save method.
  • Test case 2: Test findByEmail method for existing user.
  • Test case 3: Test findByEmail method for non-existing user.

4. Write tests.

package com.dharshi.unitTesting.repositoryTests;

@DataJpaTest
public class UserRepositoryTests {

@Autowired
private UserRepository userRepository;

@Test
@Transactional
@Rollback
public void testSaveUser() {
// Define test data
String name = "test1";
String email = "test1@example.com";

// Create a User object with the test data
User user = User.builder()
.name(name)
.email(email)
.build();

// Save the user to the database
User savedUser = userRepository.save(user);

// Assert that the retrieved user is not null
assertNotNull(savedUser);

// Assert that the retrieved user id is not null
assertNotNull(savedUser.getId());

// Assert that the retrieved user's name matches the expected name
assertEquals(name, savedUser.getName());

// Assert that the retrieved user's email matches the expected email
assertEquals(email, savedUser.getEmail());
}

@Test
@Transactional
@Rollback
public void testFindByEmailUserFound() {
// Define test data
String name = "test2";
String email = "test2@example.com";

// Create a User object with the test data
User user = User.builder()
.name(name)
.email(email)
.build();

// Save the user to the database
userRepository.save(user);

// Retrieve the user from the database using findByEmail
User foundUser = userRepository.findByEmail(email);

// Assert that the retrieved user is not null
assertNotNull(foundUser);

// Assert that the retrieved user's email matches the expected email
assertEquals(email, foundUser.getEmail());

// Assert that the retrieved user's name matches the expected name
assertEquals(name, foundUser.getName());
}

@Test
@Transactional
@Rollback
public void testFindByEmailUserNotFound() {
// Find an non existent user
User foundUser = userRepository.findByEmail("non.existent@example.com");

// Assert that the retrieved user is null
assertNull(foundUser);
}

}
  • As I mentioned earlier, @DataJpaTest is used to set up a JPA test environment with an in-memory database.
  • @Autowired injects the UserRepository into the test class.
  • The @Test annotation is used to signify that a method is a test method in JUnit. When you annotate a method with @Test, JUnit will recognize it as a test case and execute it when you run your tests.
  • @Transactionalensures that the test method runs within a transactional context. Changes made during the test are not persisted beyond the scope of the test method unless explicitly committed.
  • @Rollback Ensures that the transaction is rolled back after the test method completes, maintaining the integrity and consistency of the test database.
  • assertEquals(expected, actual) verifies that the expected value matches the actual value.
  • Please refer to the comments for the understanding of each method.

5. Run UserRepositoryTests.java to run tests.

Testing User Service

  1. Recall registerUser method
@Override
public ResponseEntity<Object> registerUser(UserDto userDto){

try {
if (userRepository.findByEmail(userDto.getEmail()) == null) {
User newUser = User.builder().name(userDto.getName()).email(userDto.getEmail()).build();

userRepository.save(newUser);

return ResponseEntity
.status(HttpStatus.CREATED)
.body("Success: User details successfully saved!");
}

} catch (Exception e) {
log.error("User registration fail: " + e.getMessage());
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Fail: Failed to process request now. Try again later");
}
return ResponseEntity.status(HttpStatus.CONFLICT).body("Fail: Email already exists!");

}

2. Write testcases

  • Test case 1: Test Successful User Registration
  • Test case 2: Test User Registration with Existing Email
  • Test case 3: Test Exception Handling

3. Write tests

@ExtendWith(MockitoExtension.class)
public class UserServiceTests {

@Mock
private UserRepository userRepository;

@InjectMocks
private UserServiceImpl userService;

private static final String TEST_NAME = "test";
private static final String TEST_EMAIL = "test@example.com";
private UserDto userDto;

@BeforeEach
void setUp() {
// Create a UserDto object with test data before running the tests
userDto = UserDto.builder().name(TEST_NAME).email(TEST_EMAIL).build();
}

@Test
public void registerUserSuccess() {
// Mock the userRepository.findByEmail method to return null,
// simulating that no user exists with the provided email
when(userRepository.findByEmail(userDto.getEmail())).thenReturn(null);

// Call the registerUser method on the userService with the userDto
ResponseEntity<?> response = userService.registerUser(userDto);

// Verify that the findByEmail method was called exactly once with the given email
verify(userRepository, times(1)).findByEmail(userDto.getEmail());

// Verify that the save method was called exactly once with any User object
verify(userRepository, times(1)).save(any(User.class));

// Assert that the response status code is HttpStatus.CREATED
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertEquals("Success: User details successfully saved!", response.getBody());
}

@Test
public void registerUserWithAlreadyExistsEmailFail() {
// Mock the userRepository.findByEmail method to return a new User object,
// simulating that a user already exists with the provided email
when(userRepository.findByEmail(userDto.getEmail())).thenReturn(new User());

// Call the registerUser method on the userService with the userDto
ResponseEntity<?> response = userService.registerUser(userDto);

// Verify that the findByEmail method was called exactly once with the given email
verify(userRepository, times(1)).findByEmail(userDto.getEmail());

// Verify that the save method was not called, as the email already exists
verify(userRepository, times(0)).save(any(User.class));

// Assert that the response status code is HttpStatus.CONFLICT
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals("Fail: Email already exists!", response.getBody());
}

@Test
public void registerUserWithInternalServerErrorFail() {
// Mock the userRepository.findByEmail method to throw a RuntimeException,
// simulating an unexpected error during the database operation
when(userRepository.findByEmail(userDto.getEmail())).thenThrow(new RuntimeException());

// Call the registerUser method on the userService with the userDto
ResponseEntity<?> response = userService.registerUser(userDto);

// Verify that the findByEmail method was called exactly once with the given email
verify(userRepository, times(1)).findByEmail(userDto.getEmail());

// Verify that the save method was not called due to the exception
verify(userRepository, times(0)).save(any(User.class));

// Assert that the response status code is HttpStatus.INTERNAL_SERVER_ERROR
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals("Fail: Failed to process request now. Try again later", response.getBody());
}

}
  • @ExtendWith(MockitoExtension.class) tells JUnit to use the MockitoExtension to initialize mocks.
  • @Mock Creates mock instances of the dependencies, here it's UserRepository.
  • @InjectMocks injects the mocks into the userService instance. @InjectMocks is a Mockito annotation used to automatically inject mock objects into the class being tested. This is especially useful for dependency injection in unit tests, where we want to test a class in isolation by providing mock implementations of its dependencies. Mockito will automatically inject mocks (annotated with @Mock) into the class annotated with @InjectMocks.
  • setUp method Initializes userDto before each test.
  • The when method in Mockito is used to stub a method call. It defines what should be returned (or thrown) when a specific method is called on a mock object.
when(mock.methodCall()).thenReturn(value);
when(mock.methodCall()).thenThrow(exception);

4. Run UserServiceTests.java to run tests.

Testing User Controller

  1. Write test cases.
  • Test case 1: Test Successful User Registration
  • Test case 2: Test User Registration with Existing Email
  • Test case 3: Test User Registration with Invalid Input
  • Test case 4: Test Exception Handling

2. Writing tests

@WebMvcTest(UserController.class)
public class UserControllerTests {

@Autowired
private MockMvc mockMvc;

@MockBean
private UserService userService;

@Test
public void registerUserSuccess() throws Exception {
// Prepare a valid UserDto request body
String userDtoJson = "{\"name\": \"Test\", \"email\": \"test@gmail.com\"}";

// Mock userService.registerUser to return a successful response
when(userService.registerUser(any())).thenReturn(ResponseEntity.status(HttpStatus.CREATED).body("Success: User details successfully saved!"));

// Perform POST request to /user/new with valid UserDto
mockMvc.perform(MockMvcRequestBuilders.post("/user/new")
.contentType(MediaType.APPLICATION_JSON)
.content(userDtoJson))
// Verify that the response status code is 201 create.
.andExpect(MockMvcResultMatchers.status().isCreated())
.andExpect(MockMvcResultMatchers.content().string("Success: User details successfully saved!"));
}

@Test
public void registerUserWithAlreadyExistsEmailFail() throws Exception {
// Prepare a valid UserDto request body
String userDtoJson = "{\"name\": \"Test\", \"email\": \"test@gmail.com\"}";

// Mock userService.registerUser to return a conflict response
when(userService.registerUser(any())).thenReturn(ResponseEntity.status(HttpStatus.CONFLICT).body("Fail: Email already exists!"));

// Perform POST request to /user/new with valid UserDto
mockMvc.perform(MockMvcRequestBuilders.post("/user/new")
.contentType(MediaType.APPLICATION_JSON)
.content(userDtoJson))
// Verify that the response status code is conflict
.andExpect(MockMvcResultMatchers.status().isConflict())
.andExpect(MockMvcResultMatchers.content().string("Fail: Email already exists!"));
}

@Test
public void registerUserWithInternalServerErrorFail() throws Exception {
// Prepare a valid UserDto request body
String userDtoJson = "{\"name\": \"Test\", \"email\": \"test@gmail.com\"}";

// Mock userService.registerUser to return a exception response
when(userService.registerUser(any())).thenReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Fail: Failed to process request now. Try again later"));

// Perform POST request to /user/new with valid UserDto
mockMvc.perform(MockMvcRequestBuilders.post("/user/new")
.contentType(MediaType.APPLICATION_JSON)
.content(userDtoJson))
// Verify that the response status code is 500 Internal server error
.andExpect(MockMvcResultMatchers.status().isInternalServerError())
.andExpect(MockMvcResultMatchers.content().string("Fail: Failed to process request now. Try again later"));
}

@Test
public void registerUserWithInvalidInputFail() throws Exception {
// Prepare an invalid UserDto request body with an no name and invalid email
String userDtoJson = "{\"email\": \"testgmail.com\"}";

// Perform a POST request to /user/new with the invalid UserDto
mockMvc.perform(MockMvcRequestBuilders.post("/user/new")
.contentType(MediaType.APPLICATION_JSON)
.content(userDtoJson))
// Verify that the response status code is 400 Bad Request
.andExpect(MockMvcResultMatchers.status().isBadRequest())
// Verify that the response body is has correctly defined errors
.andExpect(MockMvcResultMatchers.jsonPath("$.name").value("Name is required!"))
.andExpect(MockMvcResultMatchers.jsonPath("$.email").value("Email is not in valid format!"));

// Verify that the UserService's registerUser method is not called
verify(userService, times(0)).registerUser(any(UserDto.class));
}

}
  • @WebMvcTest used for testing the controller layer in isolation. Loads only the specified controller and its related components, not the entire application context.
  • @MockBean used in conjunction with @WebMvcTest to create and inject mock instances of dependencies (e.g., services) into the controller.
  • MockMvc is a Spring MVC test framework that allows you to perform HTTP requests and assert the results. Provides methods to simulate GET, POST, PUT, DELETE, and other HTTP methods. Useful for testing request and response handling in the controller.

2. Run UserControllerTests.java

That’s it guys. We have successfully tested all 3 layers for registerUser scenario. I hope this example is understandable and covered important points regarding unit testing.

4. Best Practices for Unit Testing in Spring Boot

  • Each test should verify a single aspect of the behavior of the unit under test. This makes it easier to understand what caused a test to fail.
  • Minimize the setup required for each test. Only initialize what is necessary for the specific behavior being tested.
  • Use mocking frameworks like Mockito to isolate the unit under test from its dependencies. This helps ensure that the test is focused on the behavior of the unit itself.
  • Use setup methods (e.g., @BeforeEach in JUnit) to create a consistent and reusable test environment. This includes initializing common objects and setting up necessary preconditions.
@BeforeEach
public void setUp() {
// initialize common objects or set up necessary preconditions
}
  • Create factory methods or builder patterns to generate test data. This helps avoid duplication and makes the tests more readable.
private User createUser() {
return User.builder()
.id(1)
.name("John Doe")
.email("john.doe@example.com")
.build();
}
  • Ensure that tests do not depend on each other. Each test should be able to run independently in any order.
  • Tests should not leave any residual state that could affect other tests. Use setup and teardown methods to maintain a clean state before and after each test.
@AfterEach
public void tearDown() {
// Reset mocks or any shared state if necessary
}

Unit testing is a fundamental practice in software development, especially within the context of Spring Boot applications. It ensures that individual components of your application work as expected. By isolating and testing each unit of code, you can identify and fix bugs early in the development cycle, leading to more robust and reliable applications.

Unit testing is an evolving field, and there is always more to learn. By continuously improving your unit testing practices and exploring new techniques, you’ll enhance the quality of your code and contribute to the development of more reliable and maintainable software. Keep experimenting, learning, and sharing your knowledge with the community to stay at the forefront of best practices in unit testing.

Checkout the GitHub repository of above example here.

In addition to exploring the unit testing discussed here, I invite you to delve into other insightful articles I’ve written on various spring boot topics. If you’re interested in optimizing code performance, please check out my other articles.

Thanks for taking the time to read my article. If you found it helpful, I’d really appreciate your support. Please give it a clap and follow me for more content like this in the future. Your encouragement means a lot!

Happy coding!

-Dharshi Balasubramaniam-

--

--

Dharshi Balasubramaniyam
Javarevisited

BSc (hons) in Software Engineering, UG, University of Kelaniya, Sri Lanka. Looking for software engineer/full stack developer internship roles.