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 @SpringBootTest and @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.

LibraryRole
JUnit 5 (Jupiter)Test execution engine. Recognizes @Test and manages the lifecycle
Spring Test / Spring Boot TestSpring integration test support such as @SpringBootTest, @WebMvcTest, and MockMvc
MockitoCreating mocks (@Mock / @MockBean)
AssertJFluent-style assertions with assertThat
HamcrestMatcher library
JSONassert / JsonPathVerifying JSON responses (the implementation behind jsonPath())
XMLUnitVerifying 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 as doThrow(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 an InvalidUseOfMatchersException. Wrap the raw value with eq("name").
  • Do not define stubs you do not use: MockitoExtension enables strict stubbing by default, so if a when() definition that is never called remains, the test fails with an UnnecessaryStubbingException.

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.

AnnotationStartup ScopeMain UseSpeed
@WebMvcTestController layer only (MVC-related Beans only)Controller unit testsFast
@DataJpaTestJPA-related Beans only (Repository + H2, etc.)Repository unit testsFast
@SpringBootTestThe entire application’s ApplicationContextIntegration tests / E2ESlow
@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.