Many developers reach the point where they can build applications with Spring Boot but have never written a test. If that sounds like you, this article is for you.
This article walks step by step through writing unit tests for the Controller layer and the Service layer using JUnit and Mockito. Test code acts as a safety net for refactoring and adding features, and it dramatically lowers the cost of finding bugs before they reach production. By the end, the goal is for you to be able to write basic tests for your own project on your own.
Key Tools for Testing in Spring Boot
A unit test verifies the smallest unit of code, such as a class or a method, in isolation from its other dependencies. By replacing dependent components with mocks, you can focus solely on the behavior of the code under test.
How JUnit Relates to Spring Boot Test Support
Many people search for “JUnit vs Spring Boot”, but these are not competitors. They play different roles.
- JUnit 5: The test execution engine. It is the foundation that recognizes
@Test, provides assertions, and manages the lifecycle. - Spring Boot Test (
spring-boot-starter-test): Sits on top of JUnit 5 and provides Spring-specific test support such as@SpringBootTestand@WebMvcTest. - Mockito: A library for mocking dependency objects. It works independently of JUnit.
In other words, Spring Boot tests are written on top of JUnit 5, combining Spring Boot Test and Mockito. JUnit 5 has been the default since Spring Boot 2.4. If you are migrating from JUnit 4, note that the major annotations have all changed: @RunWith becomes @ExtendWith, @Before becomes @BeforeEach, and so on. For new projects, choose JUnit 5 without hesitation.
What Is Included in spring-boot-starter-test
spring-boot-starter-test is a starter that brings in the full set of libraries you need for testing in one go.
| Library | Role |
|---|---|
| JUnit 5 (Jupiter) | Test execution engine. Recognizes @Test and manages the lifecycle |
| Spring Test / Spring Boot Test | Spring integration test support such as @SpringBootTest, @WebMvcTest, and MockMvc |
| Mockito | Creating mocks (@Mock / @MockBean) |
| AssertJ | Fluent-style assertions with assertThat |
| Hamcrest | Matcher library |
| JSONassert / JsonPath | Verifying JSON responses (the implementation behind jsonPath()) |
| XMLUnit | Verifying XML |
JUnit, Mockito, and MockMvc, all of which appear in this article, are bundled together, and Spring Boot manages version compatibility for you. There is no need to add them individually with explicit versions.
When to Use @WebMvcTest and @MockBean
- @WebMvcTest: Starts only the minimum set of components needed to test the Controller layer. It is lightweight and fast.
- @MockBean: Creates a mock Bean and registers it in the DI container. With @WebMvcTest, you use @MockBean to mock the Service that the Controller depends on.
Use @Mock for tests that run on Mockito alone, and @MockBean for tests that start a Spring context.
@MockBean Is Deprecated as of Spring Boot 3.4
In Spring Boot 3.4 (Spring Framework 6.2), @MockBean and @SpyBean were deprecated. Their successors are @MockitoBean and @MockitoSpyBean, provided by Spring Framework itself.
// Spring Boot 3.3まで
import org.springframework.boot.test.mock.mockito.MockBean;
@MockBean
private UserService userService;
// Spring Boot 3.4以降
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@MockitoBean
private UserService userService;
Usage is essentially the same, so you can migrate simply by swapping the import and the annotation name. The code examples in this article use @MockBean because it works across a wide range of versions, but for new projects on Spring Boot 3.4 or later, use @MockitoBean.
Preparing the Sample Application to Test
We will implement a simple user management API as the code under test. If you created your project with Spring Initializr, spring-boot-starter-test is included from the start, but let’s check pom.xml just to be sure.
<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;
// コンストラクタ
public User(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// ゲッター・セッター
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 and Supporting Classes
In real projects you would use Spring Data JPA, but to keep the explanation concise, prepare a Map-based @Repository class with save(), findById(), and findAll(). Also create a UserNotFoundException that extends RuntimeException, and a UserCreateRequest DTO with name and email fields.
UserService
The Service is responsible for business logic. It includes handling that throws an exception when a user does not exist.
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
For the Controller layer’s error-case tests to work, implement a @RestControllerAdvice that catches UserNotFoundException and returns a 404 status with an error response. The implementation is explained in How to Handle Exceptions in a Spring Boot REST API.
UserController
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());
}
}
Each layer is kept loosely coupled through dependency injection (DI), which makes the design easy to test. The Controller is managed by Spring as a @RestController, a specialization of @Component.
Writing Unit Tests for the Service Layer
Let’s start with tests for the Service layer. We mock the Repository and test only the Service’s business logic.
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: テストデータの準備
Long userId = 1L;
User expectedUser = new User(userId, "太郎", "[email protected]");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// When: テスト対象のメソッド実行
User actualUser = userService.getUserById(userId);
// Then: 結果の検証
assertNotNull(actualUser);
assertEquals(expectedUser.getId(), actualUser.getId());
assertEquals(expectedUser.getName(), actualUser.getName());
assertEquals(expectedUser.getEmail(), actualUser.getEmail());
verify(userRepository, times(1)).findById(userId);
}
}
There are three key points. @ExtendWith(MockitoExtension.class) integrates Mockito into JUnit 5, @Mock creates a mock of UserRepository, and @InjectMocks assembles the class under test (UserService) with that mock injected. The test body follows the three-part Given-When-Then structure. Separating setup, execution, and verification makes tests much easier to read.
Testing the Error Case
Testing error cases is just as important as testing the happy path. Here we 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 = "太郎";
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());
verify(userRepository, times(1)).save(any(User.class));
}
any(User.class) lets you define the behavior for any User object that is passed in.
How to Use Mockito’s when/thenReturn
The when().thenReturn() we have been using in the tests so far is Mockito’s basic API for defining a mock’s return value. Here is a summary of how to write it for each pattern.
// 基本形: 引数に応じた戻り値を定義
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// 任意の引数にマッチさせる
when(userRepository.findById(anyLong())).thenReturn(Optional.of(user));
// 例外をスローさせる
when(userRepository.findById(999L)).thenThrow(new IllegalStateException("DB error"));
// 呼び出しごとに異なる値を返す
when(userRepository.findAll())
.thenReturn(List.of(user1)) // 1回目の呼び出し
.thenReturn(List.of(user1, user2)); // 2回目以降の呼び出し
There are three common pitfalls to watch out for.
when()cannot be used on void methods: If you want a void method to throw an exception, use the do-family API, such asdoThrow(new RuntimeException()).when(mock).deleteById(1L);.- Use argument matchers consistently across all arguments: Mixing matchers and raw values, as in
when(service.find(anyLong(), "name")), causes anInvalidUseOfMatchersException. Wrap the raw value witheq("name"). - Do not define stubs you do not use:
MockitoExtensionenables strict stubbing by default, so if awhen()definition that is never called remains, the test fails with anUnnecessaryStubbingException.
Note that if you do not define a return value, the mock returns null for reference types and 0 or false for primitives. If you forget to set up the return value of a method that the code under test calls, it can lead to a NullPointerException, so be careful.
How to Use Mockito’s verify
verify() is the API for checking whether a mock’s method was called as expected. Whereas assertions look at the “result” of a test, verify() looks at the “process” (the interactions with the mock).
// 1回呼ばれたことを検証(times(1)は省略可能)
verify(userRepository).findById(1L);
verify(userRepository, times(1)).findById(1L);
// 一度も呼ばれていないことを検証
verify(userRepository, never()).deleteById(anyLong());
// 回数の範囲を検証
verify(userRepository, atLeastOnce()).findAll();
verify(userRepository, atMost(2)).findAll();
// モックが一切使われていないことを検証
verifyNoInteractions(mailSender);
If you want to verify the contents of the arguments passed to the mock, use ArgumentCaptor.
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("太郎", savedUser.getName());
assertEquals("[email protected]", savedUser.getEmail());
One thing to be careful about is overusing verify(). If you tightly constrain even the number of internal implementation calls, you end up with “brittle tests” that break every time you refactor. In practice, limit verification to important interactions that are observable from the outside, such as “it was saved” or “a notification was sent”.
Writing Unit Tests for the Controller Layer
Controller layer tests verify that HTTP requests and responses are handled correctly. We use @WebMvcTest and MockMvc.
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, "太郎", "[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("太郎"))
.andExpect(jsonPath("$.email").value("[email protected]"));
verify(userService, times(1)).getUserById(userId);
}
}
The flow is: @WebMvcTest(UserController.class) starts only the target Controller, MockMvc simulates HTTP requests, and andExpect() verifies the status code and JSON content. Because @WebMvcTest does not start Service Beans, trying to @Autowired a Service results in an error because the Bean cannot be found. The correct approach is to mock the dependent Service with @MockBean. If you want to use the real Service, choose @SpringBootTest + @AutoConfigureMockMvc, described later.
Testing a POST Request
@Test
void createUser_shouldReturnCreatedUser() throws Exception {
// Given
User createdUser = new User(1L, "太郎", "[email protected]");
when(userService.createUser("太郎", "[email protected]")).thenReturn(createdUser);
// When & Then
mockMvc.perform(
post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"太郎\",\"email\":\"[email protected]\"}")
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("太郎"))
.andExpect(jsonPath("$.email").value("[email protected]"));
verify(userService, times(1)).createUser("太郎", "[email protected]");
}
For POST, .contentType() and .content() set the request body, and status().isCreated() verifies that 201 is returned. For testing requests that involve validation, see also How to Implement Validation with the @Valid Annotation.
Testing the Error Case (404 Error)
@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 implemented.
Running the Tests
In an IDE, simply right-click the test class and run it (in IntelliJ IDEA choose “Run”; in Eclipse choose “Run As” then “JUnit Test”). From the command line, you can run all tests with the following.
./mvnw test
To run only a specific test class or method, filter with -Dtest.
./mvnw test -Dtest=UserServiceTest
./mvnw test -Dtest=UserServiceTest#getUserById_shouldReturnUser_whenUserExists
After the run, a summary such as Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 is displayed, and on failure, detailed output shows which assertion failed.
Choosing Between @WebMvcTest and @SpringBootTest
Spring Boot’s test annotations differ in the scope of the context they start. Choose according to your purpose.
| Annotation | Startup Scope | Main Use | Speed |
|---|---|---|---|
@WebMvcTest | Controller layer only (MVC-related Beans only) | Controller unit tests | Fast |
@DataJpaTest | JPA-related Beans only (Repository + H2, etc.) | Repository unit tests | Fast |
@SpringBootTest | The entire application’s ApplicationContext | Integration tests / E2E | Slow |
@SpringBootTest
@AutoConfigureMockMvc
class UserIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void createAndGetUser_endToEnd() throws Exception {
// Controllerから実DBまで通しでテスト
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"花子\",\"email\":\"[email protected]\"}"))
.andExpect(status().isCreated());
}
}
@SpringBootTest starts a real ApplicationContext, so it can verify integration, but startup takes several seconds. Write tests with @WebMvcTest / @DataJpaTest by default, and use @SpringBootTest only to confirm integration. That is a well-balanced setup. If your whole test suite feels slow, then in addition to slicing @SpringBootTest, aligning the combination of @MockBean across classes so the context cache takes effect brings significant improvement.
Testing the Repository Layer with @DataJpaTest
For the Repository layer, @DataJpaTest starts only the JPA-related Beans and an in-memory database (H2), so you can verify behavior quickly.
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findByEmail_shouldReturnUser_whenEmailExists() {
User saved = userRepository.save(new User(null, "太郎", "[email protected]"));
Optional<User> found = userRepository.findByEmail("[email protected]");
assertTrue(found.isPresent());
assertEquals(saved.getId(), found.get().getId());
}
}
@DataJpaTest rolls back the transaction after each test by default, which keeps tests independent of one another.
Measuring Test Coverage with JaCoCo
Once you have written tests, measure how much of your code they cover with JaCoCo.
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
Running ./mvnw test outputs an HTML report to target/site/jacoco/index.html. In practice, a realistic approach is to aim for 70-80% line coverage while also paying attention to branch coverage for important business logic.
Key Points When Implementing Tests
When testing exception handlers, you may also need to verify custom ProblemDetail fields or trace IDs attached via MDC. Production-quality exception handler design is covered in detail in Implementing a Spring Boot GlobalExceptionHandler for Production.
Also, when applying custom validation to request DTOs, your test strategy changes as well. For implementing and testing custom validation, see How to Create Custom Validation Annotations in Spring Boot, and for testing event-driven processing, see How to Decouple Modules with Spring Boot’s ApplicationEvent.
Summary
In this article, we learned the basics of unit testing with JUnit and Mockito.
- Service layer tests: Mock the Repository with @Mock and test business logic in isolation
- Controller layer tests: Verify HTTP requests and responses with @WebMvcTest and MockMvc
- Choosing the right mock: Use @MockBean or @Mock depending on whether a Spring context is involved
- Test both the happy path and error cases: Don’t forget to verify error scenarios
Use descriptive test names, follow the Given-When-Then pattern for readability, and start by writing just one test for the Service layer.