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 @Mock UserRepository is automatically injected into UserService.
  • when().thenReturn(): Defines mock behavior. This configures the mock to return the specified User object when userRepository.findById(userId) is called.
  • verify(): Verifies that a method was called as expected. Here we confirm that findById was 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 UserService and 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

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

Eclipse

  1. Right-click the test class or test method
  2. Select “Run As” → “JUnit Test”
  3. 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 @Mock and test business logic in isolation
  • Controller layer tests: Use @WebMvcTest and MockMvc to verify HTTP requests and responses
  • Choosing the right mock annotation: Use @MockBean and @Mock appropriately 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.