Translating now.

Having Spring Boot application development skills but never writing test code — that’s probably a common situation.

This article walks through, step by step, how to write unit tests for the Controller layer and Service layer using JUnit and Mockito. Test code acts as a safety net for refactoring and adding new features, and significantly reduces the cost of catching bugs before they reach production. By the end, you should be able to write basic tests independently in your own projects.

Key tools used for Spring Boot testing

A unit test verifies the smallest unit—a class or method—in isolation from its other dependencies. By replacing dependent components with mocks, you can focus purely on the behavior of the subject under test.

The relationship between JUnit and Spring Boot test support

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

  • JUnit 5: The test execution engine. The foundation that handles recognizing @Test, assertions, and lifecycle management.
  • Spring Boot Test (spring-boot-starter-test): Builds on top of JUnit 5 to provide Spring-specific test support such as @SpringBootTest and @WebMvcTest.
  • Mockito: A library for mocking dependent objects. It operates independently of JUnit.

In other words, Spring Boot tests are written on a foundation of JUnit 5, combined with Spring Boot Test + Mockito. JUnit 5 has been the default since Spring Boot 2.4. If you’re migrating from JUnit 4, note that major annotations have been overhauled—@RunWith becomes @ExtendWith, @Before becomes @BeforeEach, and so on—so for new projects, just go with JUnit 5 without hesitation.

What’s included in spring-boot-starter-test

spring-boot-starter-test is a starter that bundles together the full set of libraries needed for testing.

LibraryRole
JUnit 5 (Jupiter)Test execution engine. Recognizes @Test and manages lifecycle
Spring Test / Spring Boot TestSpring integration test support such as @SpringBootTest, @WebMvcTest, MockMvc
MockitoCreating mocks (@Mock / @MockBean)
AssertJFluent-style assertions via 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 included out of the box, and version consistency is managed on the Spring Boot side. There’s no need to add them individually with specific versions.

When to use @WebMvcTest vs. @MockBean

  • @WebMvcTest: Starts only the minimum components needed to test the Controller layer alone. It’s lightweight and fast.
  • @MockBean: Creates a mock Bean and registers it in the DI container. With @WebMvcTest, the Service that the Controller depends on is mocked using @MockBean.

Use @Mock for tests that run on Mockito alone, and @MockBean for tests that start up a Spring context.

@MockBean is deprecated in Spring Boot 3.4 and later

As of Spring Boot 3.4 (Spring Framework 6.2), @MockBean / @SpyBean have been deprecated. Their successors are @MockitoBean / @MockitoSpyBean, provided by Spring Framework itself.

// Up to Spring Boot 3.3
import org.springframework.boot.test.mock.mockito.MockBean;

@MockBean
private UserService userService;

// Spring Boot 3.4 and later
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@MockitoBean
private UserService userService;

Usage is basically the same—you can migrate just by swapping the import and the annotation name. The code examples in this article use @MockBean, since it works across a wide range of versions, but for new projects on Spring Boot 3.4 or later, use @MockitoBean.

Preparing a sample application to test

Let’s implement a simple user management API as our subject under test. spring-boot-starter-test is included by default in projects created via Spring Initializr, but let’s double-check pom.xml just in case.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

The 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/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; }
}

In real-world projects you’d use Spring Data JPA, but to keep things simple here, prepare a Map-based @Repository class with save(), findById(), and findAll(). Alongside it, also create a UserNotFoundException that extends RuntimeException, and a UserCreateRequest DTO that holds name and email.

UserService

The Service handles business logic. It includes logic to throw an exception when a 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 the Controller layer’s failure-path tests work, implement a @RestControllerAdvice that catches UserNotFoundException and returns a 404 status along with an error response. The implementation approach is covered in How to Handle Exceptions in Spring Boot REST APIs.

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 via dependency injection (DI), which makes for a design that’s easy to test. The Controller is managed by Spring as @RestController, a specialization of @Component.

Writing unit tests for the Service layer

Let’s start with testing the Service layer. We’ll 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: prepare test data
        Long userId = 1L;
        User expectedUser = new User(userId, "太郎", "[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);
    }
}

There are three key points. @ExtendWith(MockitoExtension.class) wires Mockito into JUnit 5, @Mock creates a mock of UserRepository, and @InjectMocks assembles the subject under test (UserService) with that mock injected. The test body follows the Given-When-Then three-part structure—separating setup, execution, and verification makes it easier to read.

Testing the failure path

Testing the failure path is just as important as the happy path. Let’s test the behavior when a user isn’t 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 behavior for when any User object is passed in.

How to use Mockito’s when/thenReturn

when().thenReturn(), which we’ve already used in the tests above, is Mockito’s basic API for defining a mock’s return value. Let’s organize the common patterns.

// Basic form: define a return value based on the argument
when(userRepository.findById(1L)).thenReturn(Optional.of(user));

// Match any argument
when(userRepository.findById(anyLong())).thenReturn(Optional.of(user));

// Throw an exception
when(userRepository.findById(999L)).thenThrow(new IllegalStateException("DB error"));

// Return a different value on each call
when(userRepository.findAll())
    .thenReturn(List.of(user1))          // First call
    .thenReturn(List.of(user1, user2));  // Second and later calls

There are three gotchas worth noting.

  • when() can’t be used for void methods: To make a void method throw an exception, use a do-style API instead, like doThrow(new RuntimeException()).when(mock).deleteById(1L);.
  • Keep argument matchers consistent across all arguments: Mixing matchers and literal values, as in when(service.find(anyLong(), "name")), triggers an InvalidUseOfMatchersException. Wrap the literal value with eq("name").
  • Don’t define stubs you never use: MockitoExtension enables strict stubbing by default, so leaving behind a when() definition that’s never actually called will fail the test with an UnnecessaryStubbingException.

Note that if you don’t define a return value, a mock returns null for reference types and 0 or false for primitives. Forgetting to set a return value for a method the subject under test calls can lead to a NullPointerException, so be careful.

How to use Mockito’s verify

verify() is an API for checking “was the mock’s method called as expected.” Whereas assertions look at the test’s “result,” verify() looks at the “process” (the interaction with the mock).

// Verify it was called once (times(1) can be omitted)
verify(userRepository).findById(1L);
verify(userRepository, times(1)).findById(1L);

// Verify it was never called
verify(userRepository, never()).deleteById(anyLong());

// Verify a range of call counts
verify(userRepository, atLeastOnce()).findAll();
verify(userRepository, atMost(2)).findAll();

// Verify the mock was never used at all
verifyNoInteractions(mailSender);

Verifying argument contents with ArgumentCaptor

When you want to verify the actual contents of the arguments passed to a 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 watch out for is overusing verify(). Tying tests too tightly to internal implementation call counts produces “brittle tests” that break with every refactor. In practice, it’s better to limit verification to important, externally observable interactions—things like “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’ll 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);
    }
}

@WebMvcTest(UserController.class) starts up only the target Controller, MockMvc simulates HTTP requests, and andExpect() verifies the status code and JSON content. Since @WebMvcTest doesn’t start up Service Beans, trying to @Autowired the Service would fail because the Bean can’t be found. The correct approach is to mock the dependent Service with @MockBean; if you want to use the real Service, choose @SpringBootTest + @AutoConfigureMockMvc, covered below.

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, we set the request body with .contentType() and .content(), and verify that 201 is returned with status().isCreated(). For testing requests that include validation, also see How to Implement Validation with the @Valid Annotation.

Testing the failure path (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 mentioned earlier is implemented.

Running the tests

In an IDE, you can just right-click the test class and run it (IntelliJ IDEA: “Run”; Eclipse: “Run As” → “JUnit Test”). From the command line, run all tests with the following.

./mvnw test

To run only a specific test class or method, narrow it down with -Dtest.

./mvnw test -Dtest=UserServiceTest
./mvnw test -Dtest=UserServiceTest#getUserById_shouldReturnUser_whenUserExists

After running, you’ll see a summary like Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, and on failure, detailed output showing exactly which assertion failed.

Choosing between @WebMvcTest and @SpringBootTest

Spring Boot’s test annotations differ in the scope of context they start up. 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
@SpringBootTestFull application ApplicationContextIntegration / E2E testsSlow
@SpringBootTest
@AutoConfigureMockMvc
class UserIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void createAndGetUser_endToEnd() throws Exception {
        // Test end-to-end, from the Controller down to the real DB
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"花子\",\"email\":\"[email protected]\"}"))
            .andExpect(status().isCreated());
    }
}

@SpringBootTest starts up a real ApplicationContext, so it can verify integration, but it takes a few seconds to start up. A well-balanced setup is to normally write tests with @WebMvcTest/@DataJpaTest, and reserve @SpringBootTest for verifying integration alone. If you find the overall test suite running slow, besides slicing with @SpringBootTest, aligning the combination of @MockBeans across classes so the context cache kicks in can bring a major improvement.

Testing the Repository layer with @DataJpaTest

For the Repository layer, @DataJpaTest starts up only JPA-related Beans and an in-memory DB (H2), letting you 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’ve written tests, let’s measure how much of the code is covered using 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, it’s realistic to aim for 70–80% line coverage while also keeping an eye on branch coverage for important business logic.

Testing multiple patterns at once with JUnit 5’s DynamicTest

If writing many similar-looking test methods feels tedious, there’s an approach using JUnit 5’s @TestFactory and DynamicTest.

@TestFactory
Stream<DynamicTest> createUser_variousInputs() {
    return Stream.of("太郎", "花子").map(name ->
        DynamicTest.dynamicTest("name=" + name, () -> {
            User result = userService.createUser(name, name + "@example.com");
            assertNotNull(result);
        })
    );
}

You can stream a set of input patterns through and dynamically generate test cases. Keep this in mind as an option when you have a large number of patterns.

Testing multiple patterns at once with JUnit 5’s DynamicTest

If writing many similar-looking test methods feels tedious, there’s an approach using JUnit 5’s @TestFactory and DynamicTest.

@TestFactory
Stream<DynamicTest> createUser_variousInputs() {
    return Stream.of("太郎", "花子").map(name ->
        DynamicTest.dynamicTest("name=" + name, () -> {
            User result = userService.createUser(name, name + "@example.com");
            assertNotNull(result);
        })
    );
}

You can stream a set of input patterns through and dynamically generate test cases. Keep this in mind as an option when you have a large number of patterns.

Once you’ve written tests, let’s measure how much of the code is covered using 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, it’s realistic to aim for 70–80% line coverage while also keeping an eye on branch coverage for important business logic.

Key points when implementing tests

For exception handler tests, you may also need to verify custom ProblemDetail fields and trace ID assignment via MDC. Designing a production-grade exception handler is covered in detail in Implementing Spring Boot’s GlobalExceptionHandler for Production Use.

Also, when applying custom validation to request DTOs, the testing strategy changes as well. For implementing and testing custom validation, see How to Create a Custom Validation Annotation in Spring Boot, and for testing event-driven processing, see Decoupling Modules with Spring Boot’s ApplicationEvent.

Summary

In this article, we covered the basics of unit testing with JUnit and Mockito.

  • Testing the Service layer: Mock the Repository with @Mock and test business logic in isolation
  • Testing the Controller layer: Verify HTTP requests and responses using @WebMvcTest and MockMvc
  • Choosing between mocks: Use @MockBean or @Mock depending on whether a Spring context is involved
  • Test both the happy path and the failure path: Don’t forget to verify error cases too

Make your test names descriptive, write them for readability using the Given-When-Then pattern, and start with just one Service layer test first.