You’ve learned how to develop applications with Spring Boot, but you haven’t written test code yet. Many developers find themselves in this situation.

This article walks beginners through writing unit tests for the Controller and Service layers in Spring Boot applications using JUnit and Mockito, step by step. By the end of the article, you should be able to write basic test code for your own projects on your own.

Why Spring Boot Applications Need Tests

There are three main reasons to write test code in real-world projects.

First, manual testing has its limits as an application grows. With automated tests, you can verify all functionality in seconds to minutes with a single command.

Second, tests serve as a safety net during refactoring and feature additions. With test code in place, you can immediately verify that changes haven’t broken existing functionality.

Finally, early bug detection leads to cost savings. Finding bugs during development is far cheaper than discovering them after release to production.

Key Tools for Testing Spring Boot

The Relationship Between JUnit and Spring Boot Test Support

Many people search for “JUnit vs Spring Boot,” but these aren’t competitors — they serve different roles.

  • JUnit 5: The test execution engine. The foundation that handles @Test recognition, assertions, and lifecycle management.
  • Spring Boot Test (spring-boot-starter-test): Layered on top of JUnit 5, it provides Spring-specific test support such as @SpringBootTest and @WebMvcTest.
  • Mockito: A library for mocking dependent objects. It works independently of JUnit and is used via @MockitoExtension or @MockBean.

In other words, Spring Boot application tests are written by combining Spring Boot Test + Mockito on top of JUnit 5. Some existing projects use JUnit 4, but JUnit 5 has been the default since Spring Boot 2.4.

What Is a Unit Test?

A unit test verifies the smallest unit of an application (a class or method) in isolation from its dependencies. By replacing dependent components with mocks, you can focus solely on the behavior of the unit under test.

JUnit 5 and Mockito

JUnit 5 is a framework for running Java tests. Methods annotated with @Test are recognized as tests.

Mockito is a library for creating “mocks” (fakes) of dependent objects. For example, when testing the Service layer, you can use mocks instead of the real Repository to test logic without a database.

These are included in spring-boot-starter-test, so no special configuration is required.

When to Use @WebMvcTest vs @MockBean

  • @WebMvcTest: Starts only the minimum components needed to test the Controller layer. Lightweight and fast.
  • @MockBean: Creates a mock Bean and registers it with the DI container. With @WebMvcTest, you use @MockBean to mock the Service that the Controller depends on.

For unit tests, using @WebMvcTest to start only the layer under test can dramatically improve test execution speed.

Preparing a Sample Application to Test

Before writing tests, let’s implement a simple user management API to test against.

pom.xml Dependency Configuration

If you’re using Spring Boot 3.2 or later, spring-boot-starter-test is included automatically. Just to be safe, verify that your pom.xml has the following dependency.

<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

In real 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 an @Repository class with three methods: save(), findById(), and findAll().

UserNotFoundException

Create a custom exception class called UserNotFoundException that extends RuntimeException.

UserService

The Service is responsible for business logic. Here, we include logic that throws an exception if the user doesn’t 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

To make Controller-layer abnormal-case tests work, we handle exceptions with @RestControllerAdvice. Implement it to catch UserNotFoundException with @ExceptionHandler and return a 404 status with an error response. For more details on exception handling implementation, 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 for receiving the POST request body. Implement it as a simple POJO with name and email fields.

In this application, each layer has clearly separated responsibilities.

  • Controller: HTTP request handling
  • Service: Business logic
  • Repository: Data persistence

The layers are loosely coupled through 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 Service layer tests. In Service layer tests, we mock the Repository and test only the Service’s business logic.

Basic Structure of a Service Test

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);
    }
}

Code Explanation

  • @ExtendWith(MockitoExtension.class): Configuration for using Mockito 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 @Mock UserRepository is automatically injected into UserService.
  • when().thenReturn(): Defines the mock’s behavior. Here, we configure it to return the specified User object when userRepository.findById(userId) is called.
  • verify(): Verifies that the method was called as expected. Here, we confirm that findById was called exactly once.

Testing the Abnormal Case

Beyond the happy path, abnormal-case tests are also important. Let’s test the behavior when the 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());
    assertEquals(name, result.getName());
    assertEquals(email, result.getEmail());
    verify(userRepository, times(1)).save(any(User.class));
}

Using any(User.class) lets you define behavior for when any User object is passed.

Writing Unit Tests for the Controller Layer

Controller layer tests verify that HTTP requests and responses are processed correctly. We use @WebMvcTest and MockMvc.

Basic Structure of a Controller Test

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);
    }
}

Code Explanation

  • @WebMvcTest(UserController.class): Starts only the minimum components needed to test UserController.
  • @Autowired MockMvc: A tool for simulating HTTP requests.
  • @MockBean UserService: Mocks UserService and registers it with the Spring ApplicationContext.
  • mockMvc.perform(): Simulates an HTTP request.
  • andExpect(): Verifies the response. You can verify status code, Content-Type, JSON content, and more.
  • jsonPath(): Verifies specific fields in the JSON response.

Testing POST Requests

@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 requests, specify the Content-Type with .contentType() and set the request body with .content(). We also verify that a 201 status code is returned with .andExpect(status().isCreated()).

For testing requests with validation, also see How to Implement Validation with the @Valid Annotation.

Testing the Abnormal 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 of the GlobalExceptionHandler described earlier. @RestControllerAdvice handles the exception appropriately and returns a 404 status with an error response.

Running Tests

Once you’ve written test code, run it and check the results.

Running Tests in Your IDE

IntelliJ IDEA

  1. Right-click the test class or test method
  2. Select “Run ‘test name’”
  3. Test results appear at the bottom of the screen

Eclipse

  1. Right-click the test class or test method
  2. Select “Run As” → “JUnit Test”
  3. Test results appear in the JUnit view

Running Tests with Maven Commands

To run all tests from the command line, execute the following command in the project’s root directory.

./mvnw test

On Windows, run mvnw.cmd test.

Running Specific Tests

To run only a specific test class, do the following.

./mvnw test -Dtest=UserServiceTest

To run only a specific test method, do the following.

./mvnw test -Dtest=UserServiceTest#getUserById_shouldReturnUser_whenUserExists

How to Read Test Results

When tests pass, you’ll see output like the following.

[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 where an unexpected error occurred
  • Skipped: Number of tests that were skipped

When tests fail, you’ll see detailed information about which tests failed and which assertions failed.

Key Points When Implementing Tests

One area where people often get stuck is handling mocks. When using @WebMvcTest, the Service that the Controller depends on must be mocked with @MockBean. Also, if you don’t define mock behavior with when().thenReturn(), null is returned by default, causing a NullPointerException.

The distinction between @Mock and @MockBean is also important. Use @Mock in pure Mockito tests, and @MockBean in tests that start the Spring context.

When testing exception handlers, you may need to verify custom ProblemDetail fields or trace IDs added via MDC. For production-quality exception handler design, see Implementing Production-Ready GlobalExceptionHandler in Spring Boot.

In cases where you apply custom validation to request DTOs, the test strategy also changes. For implementing and testing custom validation, see How to Create Custom Validation Annotations in Spring Boot, and for testing event-driven processing, also check How to Decouple Modules with ApplicationEvent in Spring Boot.

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: Use @WebMvcTest and MockMvc to verify HTTP requests and responses
  • Choosing the right mock: Use @MockBean and @Mock appropriately
  • Test both happy and abnormal paths: Don’t forget to verify error cases

In practice, it’s important to use descriptive test names and write them in a readable Given-When-Then pattern. Repository layer tests and integration tests will be covered in detail in future articles.

When to Use @WebMvcTest vs @SpringBootTest

We used @WebMvcTest for unit tests, but Spring Boot offers several other test annotations. Since each starts a different scope of context, you need to choose based on 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 / E2E testsSlow

Example of Integration Testing with @SpringBootTest

@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 the integration of Controller → Service → Repository, but it takes several seconds to start up. A well-balanced setup is to write tests with @WebMvcTest/@DataJpaTest normally, and use @SpringBootTest only when you need to verify integration.

Testing the Repository Layer with @DataJpaTest

For the Repository layer, using @DataJpaTest starts only JPA-related Beans and an in-memory DB (H2), allowing you to verify Repository 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 for each test by default, maintaining independence between tests.

Measuring Test Coverage with JaCoCo

Once you’ve written tests, measure how much of your code is covered. JaCoCo is the most widely used coverage measurement tool in Java and can be integrated with both Maven and Gradle.

<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. You can check coverage by class, method, line, and branch. In practice, aiming for 70-80% line coverage while paying attention to branch coverage for critical business logic is realistic.

Frequently Asked Questions (FAQ)

How do I choose between @Mock and @MockBean?

  • @Mock: Works with pure Mockito. Use it in lightweight tests that don’t start the Spring context (@ExtendWith(MockitoExtension.class)).
  • @MockBean: A mock that Spring Test registers with the ApplicationContext. Use it in tests that start the Spring context, such as @WebMvcTest or @SpringBootTest.

What’s the difference between JUnit 4 and JUnit 5?

The default since Spring Boot 2.4 is JUnit 5. Major annotations have changed, such as @RunWith@ExtendWith and @Before@BeforeEach. For new projects, choose JUnit 5 without hesitation.

Service can’t be @Autowired with @WebMvcTest

Since @WebMvcTest only starts the Controller layer, no Service Bean exists. The correct approach is to mock it with @MockBean. If you want to use the real Service, choose @SpringBootTest + @AutoConfigureMockMvc.

What happens if I don’t set a return value for a mock?

If you don’t define a return value with when().thenReturn(), a Mockito mock returns null for reference types and 0/false for primitives. This often causes NullPointerException, so always set return values for methods that the unit under test calls.

What if Spring Boot Test runs slowly?

Heavy use of @SpringBootTest increases the number of context startups, making tests slow. You can dramatically improve this by (1) slicing tests with @WebMvcTest/@DataJpaTest and (2) aligning your @MockBean combinations so that context caching takes effect.