Many developers have learned to build applications with Spring Boot but have never written test code. If that sounds like you, you’re not alone.
This article is a step-by-step guide to writing your first unit tests in a Spring Boot application — covering both the Controller layer and Service layer using JUnit and Mockito. By the end, you should be able to write basic test code independently in your own projects.
Why Spring Boot Applications Need Tests
There are three main reasons to write test code in professional development.
First, as an application grows, manual testing hits its limits. With automated tests, you can verify all functionality in seconds or minutes with a single command.
Second, tests act as a safety net during refactoring and feature additions. With test code in place, you can instantly confirm that existing functionality hasn’t broken after a change.
Third, catching bugs early reduces costs. Finding a bug during development is far cheaper to fix than discovering it after it’s been released to production.
Key Tools for Testing in Spring Boot
What Is a Unit Test?
A unit test tests the smallest unit of an application (a class or method) in isolation from its dependencies. By replacing dependencies with mocks, you can focus exclusively on the behavior of the component under test.
JUnit 5 and Mockito
JUnit 5 is the framework for running Java tests. Methods annotated with @Test are recognized as tests.
Mockito is a library for creating “mocks” (fake objects) of dependencies. For example, when testing the Service layer, you can use a mock instead of a real Repository to test only the business logic without a database.
Both are included in spring-boot-starter-test, so no additional setup is needed.
When to Use @WebMvcTest vs @MockBean
- @WebMvcTest: Starts only the minimum components needed to test the Controller layer. It’s lightweight and fast.
- @MockBean: Creates a mock Bean and registers it in the DI container. When using @WebMvcTest, you mock the Services that the Controller depends on with @MockBean.
Using @WebMvcTest — which only starts the layer under test — can dramatically improve test execution speed.
Setting Up the Sample Application
Before writing tests, let’s implement a simple user management API to test against.
Dependency Configuration in pom.xml
For projects on Spring Boot 3.2 or later, spring-boot-starter-test is included automatically. Just confirm the following dependency exists in your pom.xml.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
User Entity
package com.example.demo.model;
public class User {
private Long id;
private String name;
private String email;
// Constructor
public User(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
UserRepository
In real-world projects you’d use a Spring Data JPA Repository interface, but here we’ll use a simple Map-based implementation to keep the test explanation concise. Implement it as a @Repository class with three methods: save(), findById(), and findAll().
UserNotFoundException
Create a custom exception class UserNotFoundException that extends RuntimeException.
UserService
The Service handles business logic. Here we include logic that throws an exception when a user is not found.
package com.example.demo.service;
import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(String name, String email) {
User user = new User(null, name, email);
return userRepository.save(user);
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}
public List<User> getAllUsers() {
return userRepository.findAll();
}
}
GlobalExceptionHandler
To make error-case Controller tests work correctly, handle exceptions with @RestControllerAdvice. Implement it to catch UserNotFoundException via @ExceptionHandler and return a 404 status with an error response. For a detailed guide on exception handling, see How to Handle Exceptions in Spring Boot REST APIs.
UserController
The Controller receives HTTP requests, calls the Service, and returns the result.
package com.example.demo.controller;
import com.example.demo.dto.UserCreateRequest;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
@GetMapping
public List<User> getAllUsers() {
return userService.getAllUsers();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public User createUser(@RequestBody UserCreateRequest request) {
return userService.createUser(request.getName(), request.getEmail());
}
}
UserCreateRequest
This is a DTO class that receives the POST request body. Implement it as a simple POJO with name and email fields.
In this application, each layer has a clearly defined responsibility:
- Controller: Handles HTTP requests
- Service: Handles business logic
- Repository: Handles data persistence
Each layer is loosely coupled via Dependency Injection (DI), making the design easy to test. The Controller is managed as a Spring component via @RestController, which is a specialization of @Component.
Writing Unit Tests for the Service Layer
Let’s start with the Service layer. In Service layer tests, we mock the Repository and test only the Service’s business logic.
Basic Service Test Structure
package com.example.demo.service;
import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void getUserById_shouldReturnUser_whenUserExists() {
// Given: Prepare test data
Long userId = 1L;
User expectedUser = new User(userId, "Taro", "[email protected]");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// When: Execute the method under test
User actualUser = userService.getUserById(userId);
// Then: Verify the result
assertNotNull(actualUser);
assertEquals(expectedUser.getId(), actualUser.getId());
assertEquals(expectedUser.getName(), actualUser.getName());
assertEquals(expectedUser.getEmail(), actualUser.getEmail());
verify(userRepository, times(1)).findById(userId);
}
}
Code Walkthrough
- @ExtendWith(MockitoExtension.class): Configures Mockito for use with JUnit 5.
- @Mock: Creates a mock object. Here we create a mock of
UserRepository. - @InjectMocks: Creates the object under test with mocks injected. The
@MockUserRepositoryis automatically injected intoUserService. - when().thenReturn(): Defines mock behavior. This configures the mock to return the specified
Userobject whenuserRepository.findById(userId)is called. - verify(): Verifies that a method was called as expected. Here we confirm that
findByIdwas called exactly once.
Testing Error Cases
Error cases are just as important as the happy path. Let’s test the behavior when a user is not found.
@Test
void getUserById_shouldThrowException_whenUserNotFound() {
// Given
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// When & Then
UserNotFoundException exception = assertThrows(
UserNotFoundException.class,
() -> userService.getUserById(userId)
);
assertTrue(exception.getMessage().contains("User not found"));
verify(userRepository, times(1)).findById(userId);
}
assertThrows lets you verify that a specific exception is thrown.
Testing createUser
@Test
void createUser_shouldSaveAndReturnUser() {
// Given
String name = "Taro";
String email = "[email protected]";
User savedUser = new User(1L, name, email);
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// When
User result = userService.createUser(name, email);
// Then
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals(name, result.getName());
assertEquals(email, result.getEmail());
verify(userRepository, times(1)).save(any(User.class));
}
any(User.class) lets you define behavior for when any User object is passed as an argument.
Writing Unit Tests for the Controller Layer
Controller layer tests verify that HTTP requests and responses are handled correctly. We use @WebMvcTest and MockMvc.
Basic Controller Test Structure
package com.example.demo.controller;
import com.example.demo.dto.UserCreateRequest;
import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void getUser_shouldReturnUser_whenUserExists() throws Exception {
// Given
Long userId = 1L;
User user = new User(userId, "Taro", "[email protected]");
when(userService.getUserById(userId)).thenReturn(user);
// When & Then
mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Taro"))
.andExpect(jsonPath("$.email").value("[email protected]"));
verify(userService, times(1)).getUserById(userId);
}
}
Code Walkthrough
- @WebMvcTest(UserController.class): Starts only the minimum components needed to test
UserController. - @Autowired MockMvc: The tool used to simulate HTTP requests.
- @MockBean UserService: Mocks
UserServiceand registers it in the Spring ApplicationContext. - mockMvc.perform(): Simulates an HTTP request.
- andExpect(): Verifies the response — status code, Content-Type, JSON body, and more.
- jsonPath(): Verifies a specific field in the JSON response.
Testing POST Requests
@Test
void createUser_shouldReturnCreatedUser() throws Exception {
// Given
User createdUser = new User(1L, "Taro", "[email protected]");
when(userService.createUser("Taro", "[email protected]")).thenReturn(createdUser);
// When & Then
mockMvc.perform(
post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Taro\",\"email\":\"[email protected]\"}")
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Taro"))
.andExpect(jsonPath("$.email").value("[email protected]"));
verify(userService, times(1)).createUser("Taro", "[email protected]");
}
For POST requests, use .contentType() to set the Content-Type and .content() to set the request body. Use .andExpect(status().isCreated()) to verify that a 201 status code is returned.
For testing requests that include validation, see How to Implement Validation with the @Valid Annotation.
Testing Error Cases (404)
@Test
void getUser_shouldReturn404_whenUserNotFound() throws Exception {
// Given
Long userId = 999L;
when(userService.getUserById(userId))
.thenThrow(new UserNotFoundException("User not found: " + userId));
// When & Then
mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("User not found: 999"));
verify(userService, times(1)).getUserById(userId);
}
This test works correctly because the GlobalExceptionHandler described earlier is in place. The @RestControllerAdvice handles the exception and returns a 404 status with an error response.
Running Your Tests
Once you’ve written the test code, run it and check the results.
Running Tests in Your IDE
IntelliJ IDEA
- Right-click the test class or test method
- Select “Run ‘test name’”
- Results appear in the panel at the bottom of the screen
Eclipse
- Right-click the test class or test method
- Select “Run As” → “JUnit Test”
- Results appear in the JUnit view
Running Tests with the Maven Command
To run all tests from the command line, execute the following command from the project root directory.
./mvnw test
On Windows, run mvnw.cmd test.
Running Specific Tests
To run only a specific test class:
./mvnw test -Dtest=UserServiceTest
To run only a specific test method:
./mvnw test -Dtest=UserServiceTest#getUserById_shouldReturnUser_whenUserExists
Reading the Test Output
When tests pass, you’ll see output like this:
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
- Tests run: Total number of tests executed
- Failures: Number of tests where an assertion failed
- Errors: Number of tests that encountered an unexpected error
- Skipped: Number of tests that were skipped
When a test fails, the output will detail which test failed and which assertion did not hold.
Tips for Writing Tests
The trickiest part of test implementation is usually handling mocks. When using @WebMvcTest, any Services that the Controller depends on must be mocked with @MockBean. Also, if you don’t define mock behavior with when().thenReturn(), the mock returns null by default, which will cause a NullPointerException.
Knowing when to use @Mock vs @MockBean is also important. Use @Mock for pure Mockito tests, and @MockBean when starting a Spring context.
Summary
In this article, we covered the basics of unit testing with JUnit and Mockito.
- Service layer tests: Mock the Repository with
@Mockand test business logic in isolation - Controller layer tests: Use
@WebMvcTestandMockMvcto verify HTTP requests and responses - Choosing the right mock annotation: Use
@MockBeanand@Mockappropriately depending on context - Test both happy path and error cases: Don’t forget to verify error scenarios
In real-world projects, it’s important to use descriptive test names and write tests in the Given-When-Then pattern for readability. Future articles will cover Repository layer tests and integration tests in more detail.