Unit Testing Spring Boot REST API Controller

Introduction

In this tutorial, we will learn how to perform unit testing on Spring Boot CRUD RESTful web services using JUnit 5 and the Mockito framework. Testing ensures that your application’s individual components behave correctly and as expected. Unit testing is especially useful in identifying bugs early in the development cycle, thus improving the overall quality of your application.

Unit Testing Spring Boot REST API Controller

Spring Boot provides the spring-boot-starter-test dependency, which includes the necessary tools and libraries for both unit testing and integration testing of Spring Boot applications:

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

This dependency is the backbone for testing in Spring Boot and contains everything needed for testing, including JUnit, Mockito, Hamcrest, and AssertJ.

In this tutorial, we will focus on testing the Controller layer of a Spring Boot application. To achieve this, we will use the following testing libraries:

  • JUnit 5 Framework: A popular testing framework in the Java ecosystem.
  • Mockito: A mocking framework for simulating dependencies in unit tests.
  • Hamcrest: A framework for writing matchers that allow you to express the expected outcomes of your tests.
  • AssertJ: Provides fluent assertions for writing clean and readable test cases.
  • JsonPath: A library for querying JSON content.

JUnit 5 Framework

A popular testing framework in Java that allows developers to write, execute, and manage unit tests effectively.

Mockito

A mocking framework that simulates dependencies, enabling isolated unit testing by faking the behavior of external components.

Hamcrest

A matcher library that makes test assertions more readable and expressive by defining expected test outcomes.

AssertJ

A fluent assertion library that allows for writing clean, readable, and maintainable assertions in tests.

JsonPath

A tool for querying and extracting specific data from JSON content, making it easier to validate REST API responses in tests.

Tools and Technologies Used

  • Java 21+
  • Spring Boot
  • Lombok
  • JUnit 5 Framework
  • Hamcrest
  • AssertJ
  • JsonPath
  • Mockito
  • IntelliJ IDEA
  • Docker
  • Maven

Add Maven Dependencies

First, we need to add dependencies in the pom.xml to support Spring Boot, Lombok, JUnit 5, and MySQL.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.0.0</version>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

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

Project Structure

The project structure will follow the typical package organization:

src/main/java
│
├── net
│   └── javaguides
│       └── springboot
│           ├── model
│           │   └── Product.java
│           ├── repository
│           │   └── ProductRepository.java
│           ├── service
│           │   └── ProductService.java
│           ├── service
│           │   └── ProductServiceImpl.java
│           └── controller
│               └── ProductController.java
│
src/test/java
│
└── net
    └── javaguides
        └── springboot
            └── controller
                └── ProductControllerTests.java

Configure MySQL Database

The following properties should be added to the application.properties to configure MySQL:

spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://localhost:3306/product_management
spring.datasource.username=root
spring.datasource.password=Mysql@123

spring.jpa.hibernate.ddl-auto=update

Create Entity - Product JPA Entity

Let's create a Product entity which will map to the products table in the MySQL database.

import lombok.*;

import jakarta.persistence.*;

@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String description;

    @Column(nullable = false)
    private double price;
}

Explanation:

  • @Entity: Marks the class as a JPA entity.
  • @Table(name = "products"): Specifies the table name in the database.
  • Lombok Annotations: @Setter, @Getter, @AllArgsConstructor, @NoArgsConstructor, and @Builder are used to reduce boilerplate code.

Create Repository Layer - ProductRepository

The repository interface ProductRepository extends JpaRepository to provide basic CRUD operations. Here, we can also define custom queries using JPQL or native SQL.

import net.javaguides.springboot.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

Service Layer

ProductService Interface

Let's create a ProductService interface with the following content:
package net.javaguides.springboot.service;

import net.javaguides.springboot.model.Product;

import java.util.List;
import java.util.Optional;

public interface ProductService {
    Product saveProduct(Product product);
    List<Product> getAllProducts();
    Optional<Product> getProductById(long id);
    Product updateProduct(Product updatedProduct);
    void deleteProduct(long id);
}

ProductServiceImpl Class

Next, let's create a ProductServiceImpl class that implements the ProductService interface, and its methods.
package net.javaguides.springboot.service.impl;

import net.javaguides.springboot.model.Product;
import net.javaguides.springboot.repository.ProductRepository;
import net.javaguides.springboot.service.ProductService;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class ProductServiceImpl implements ProductService {

    private ProductRepository productRepository;

    public ProductServiceImpl(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public Product saveProduct(Product product) {
        return productRepository.save(product);
    }

    @Override
    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    @Override
    public Optional<Product> getProductById(long id) {
        return productRepository.findById(id);
    }

    @Override
    public Product updateProduct(Product updatedProduct) {
        return productRepository.save(updatedProduct);
    }

    @Override
    public void deleteProduct(long id) {
        productRepository.deleteById(id);
    }
}

Controller Layer - ProductController

Let's create a ProductController class. Within the ProductController class, we will build CRUD RESTful web services for the Product resource:
package net.javaguides.springboot.controller;

import net.javaguides.springboot.model.Product;
import net.javaguides.springboot.service.ProductService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Product createProduct(@RequestBody Product product){
        return productService.saveProduct(product);
    }

    @GetMapping
    public List<Product> getAllProducts(){
        return productService.getAllProducts();
    }

    @GetMapping("{id}")
    public ResponseEntity<Product> getProductById(@PathVariable("id") long productId){
        return productService.getProductById(productId)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PutMapping("{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable("id") long productId,
                                                 @RequestBody Product product){
        return productService.getProductById(productId)
                .map(savedProduct -> {

                    savedProduct.setName(product.getName());
                    savedProduct.setDescription(product.getDescription());
                    savedProduct.setPrice(product.getPrice());

                    Product updatedProduct = productService.updateProduct(savedProduct);
                    return new ResponseEntity<>(updatedProduct, HttpStatus.OK);

                })
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @DeleteMapping("{id}")
    public ResponseEntity<String> deleteProduct(@PathVariable("id") long productId){

        productService.deleteProduct(productId);

        return new ResponseEntity<>("Product deleted successfully!.", HttpStatus.OK);

    }

}

Unit Testing Controller Layer

Let's create a ProductControllerTests class. Within the ProductControllerTests class, we will write JUnit test cases to unit test CRUD RESTful web services:
package net.javaguides.springboot.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import net.javaguides.springboot.model.Product;
import net.javaguides.springboot.service.ProductService;

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 org.springframework.test.web.servlet.ResultActions;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import static org.hamcrest.CoreMatchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;

@WebMvcTest
public class ProductControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    public void givenProductObject_whenCreateProduct_thenReturnSavedProduct() throws Exception{

        // given - precondition or setup
        Product product = Product.builder()
                .name("Phone")
                .description("Smartphone")
                .price(500.0)
                .build();
        given(productService.saveProduct(any(Product.class)))
                .willAnswer((invocation)-> invocation.getArgument(0));

        // when - action or behaviour that we are going test
        ResultActions response = mockMvc.perform(post("/api/products")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(product)));

        // then - verify the result or output using assert statements
        response.andDo(print()).
                andExpect(status().isCreated())
                .andExpect(jsonPath("$.name", is(product.getName())))
                .andExpect(jsonPath("$.description", is(product.getDescription())))
                .andExpect(jsonPath("$.price", is(product.getPrice())));

    }

    // JUnit test for Get All products REST API
    @Test
    public void givenListOfProducts_whenGetAllProducts_thenReturnProductsList() throws Exception{
        // given - precondition or setup
        List<Product> listOfProducts = new ArrayList<>();
        listOfProducts.add(Product.builder().name("Phone").description("Smartphone").price(500.0).build());
        listOfProducts.add(Product.builder().name("Laptop").description("Gaming Laptop").price(1500.0).build());
        given(productService.getAllProducts()).willReturn(listOfProducts);

        // when -  action or the behaviour that we are going test
        ResultActions response = mockMvc.perform(get("/api/products"));

        // then - verify the output
        response.andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("$.size()", is(listOfProducts.size())));

    }

    // positive scenario - valid product id
    // JUnit test for GET product by id REST API
    @Test
    public void givenProductId_whenGetProductById_thenReturnProductObject() throws Exception{
        // given - precondition or setup
        long productId = 1L;
        Product product = Product.builder()
                .name("Phone")
                .description("Smartphone")
                .price(500.0)
                .build();
        given(productService.getProductById(productId)).willReturn(Optional.of(product));

        // when -  action or the behaviour that we are going test
        ResultActions response = mockMvc.perform(get("/api/products/{id}", productId));

        // then - verify the output
        response.andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("$.name", is(product.getName())))
                .andExpect(jsonPath("$.description", is(product.getDescription())))
                .andExpect(jsonPath("$.price", is(product.getPrice())));

    }

    // negative scenario - valid product id
    // JUnit test for GET product by id REST API
    @Test
    public void givenInvalidProductId_whenGetProductById_thenReturnEmpty() throws Exception{
        // given - precondition or setup
        long productId = 1L;
        Product product = Product.builder()
                .name("Phone")
                .description("Smartphone")
                .price(500.0)
                .build();
        given(productService.getProductById(productId)).willReturn(Optional.empty());

        // when -  action or the behaviour that we are going test
        ResultActions response = mockMvc.perform(get("/api/products/{id}", productId));

        // then - verify the output
        response.andExpect(status().isNotFound())
                .andDo(print());

    }

    // JUnit test for update product REST API - positive scenario
    @Test
    public void givenUpdatedProduct_whenUpdateProduct_thenReturnUpdatedProductObject() throws Exception{
        // given - precondition or setup
        long productId = 1L;
        Product savedProduct = Product.builder()
                .name("Phone")
                .description("Smartphone")
                .price(500.0)
                .build();

        Product updatedProduct = Product.builder()
                .name("Tablet")
                .description("New Tablet")
                .price(300.0)
                .build();
        given(productService.getProductById(productId)).willReturn(Optional.of(savedProduct));
        given(productService.updateProduct(any(Product.class)))
                .willAnswer((invocation)-> invocation.getArgument(0));

        // when -  action or the behaviour that we are going test
        ResultActions response = mockMvc.perform(put("/api/products/{id}", productId)
                                        .contentType(MediaType.APPLICATION_JSON)
                                        .content(objectMapper.writeValueAsString(updatedProduct)));


        // then - verify the output
        response.andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("$.name", is(updatedProduct.getName())))
                .andExpect(jsonPath("$.description", is(updatedProduct.getDescription())))
                .andExpect(jsonPath("$.price", is(updatedProduct.getPrice())));
    }

    // JUnit test for update product REST API - negative scenario
    @Test
    public void givenUpdatedProduct_whenUpdateProduct_thenReturn404() throws Exception{
        // given - precondition or setup
        long productId = 1L;
        Product savedProduct = Product.builder()
                .name("Phone")
                .description("Smartphone")
                .price(500.0)
                .build();

        Product updatedProduct = Product.builder()
                .name("Tablet")
                .description("New Tablet")
                .price(300.0)
                .build();
        given(productService.getProductById(productId)).willReturn(Optional.empty());

        // when -  action or the behaviour that we are going test
        ResultActions response = mockMvc.perform(put("/api/products/{id}", productId)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(updatedProduct)));

        // then - verify the output
        response.andExpect(status().isNotFound())
                .andDo(print());
    }

    // JUnit test for delete product REST API
    @Test
    public void givenProductId_whenDeleteProduct_thenReturn200() throws Exception{
        // given - precondition or setup
        long productId = 1L;
        willDoNothing().given(productService).deleteProduct(productId);

        // when -  action or the behaviour that we are going test
        ResultActions response = mockMvc.perform(delete("/api/products/{id}", productId));

        // then - verify the output
        response.andExpect(status().isOk())
                .andDo(print());
    }
}

@MockBean

The @MockBean annotation creates a mock instance of a Spring bean (like a service) and injects it into the test context. This allows you to test a specific layer (e.g., controllers) without invoking the actual service logic.

@Autowired private MockMvc mockMvc;

MockMvc is used to simulate HTTP requests to the web layer (controllers) in Spring Boot applications without starting a server. It helps test REST APIs by sending requests and validating responses.

@Autowired private ObjectMapper objectMapper;

ObjectMapper is used to convert Java objects to JSON and vice versa. In tests, it's commonly used to prepare request payloads and handle responses in REST API testing.

Explanation for Above JUnit Test Cases

1. Test: givenProductObject_whenCreateProduct_thenReturnSavedProduct

@Test
public void givenProductObject_whenCreateProduct_thenReturnSavedProduct() throws Exception{
    Product product = Product.builder()
            .name("Phone")
            .description("Smartphone")
            .price(500.0)
            .build();
    given(productService.saveProduct(any(Product.class)))
            .willAnswer((invocation)-> invocation.getArgument(0));

    ResultActions response = mockMvc.perform(post("/api/products")
        .contentType(MediaType.APPLICATION_JSON)
        .content(objectMapper.writeValueAsString(product)));

    response.andDo(print()).
            andExpect(status().isCreated())
            .andExpect(jsonPath("$.name", is(product.getName())))
            .andExpect(jsonPath("$.description", is(product.getDescription())))
            .andExpect(jsonPath("$.price", is(product.getPrice())));
}

Explanation:

  • Purpose: This test checks whether a product is successfully created when the POST /api/products endpoint is called.
  • Given: A Product object (Phone) is created and productService.saveProduct is mocked to return the product.
  • When: A POST request is sent to /api/products with the product data as JSON.
  • Then: The test checks if the status is 201 Created, and verifies that the product's name, description, and price match the expected values.

2. Test: givenListOfProducts_whenGetAllProducts_thenReturnProductsList

@Test
public void givenListOfProducts_whenGetAllProducts_thenReturnProductsList() throws Exception{
    List<Product> listOfProducts = new ArrayList<>();
    listOfProducts.add(Product.builder().name("Phone").description("Smartphone").price(500.0).build());
    listOfProducts.add(Product.builder().name("Laptop").description("Gaming Laptop").price(1500.0).build());
    given(productService.getAllProducts()).willReturn(listOfProducts);

    ResultActions response = mockMvc.perform(get("/api/products"));

    response.andExpect(status().isOk())
            .andDo(print())
            .andExpect(jsonPath("$.size()", is(listOfProducts.size())));
}

Explanation:

  • Purpose: This test checks if the GET /api/products endpoint returns the correct list of products.
  • Given: A list of two products is prepared, and productService.getAllProducts() is mocked to return this list.
  • When: A GET request is sent to /api/products.
  • Then: The test checks if the response status is 200 OK and verifies that the returned list size matches the expected number of products.

3. Test: givenProductId_whenGetProductById_thenReturnProductObject

@Test
public void givenProductId_whenGetProductById_thenReturnProductObject() throws Exception{
    long productId = 1L;
    Product product = Product.builder()
            .name("Phone")
            .description("Smartphone")
            .price(500.0)
            .build();
    given(productService.getProductById(productId)).willReturn(Optional.of(product));

    ResultActions response = mockMvc.perform(get("/api/products/{id}", productId));

    response.andExpect(status().isOk())
            .andDo(print())
            .andExpect(jsonPath("$.name", is(product.getName())))
            .andExpect(jsonPath("$.description", is(product.getDescription())))
            .andExpect(jsonPath("$.price", is(product.getPrice())));
}

Explanation:

  • Purpose: This test verifies that the GET /api/products/{id} endpoint returns the correct product when provided with a valid product ID.
  • Given: A product (Phone) is created and productService.getProductById is mocked to return this product when called with the given ID.
  • When: A GET request is sent to /api/products/{id}.
  • Then: The test asserts that the status is 200 OK and verifies that the returned product's name, description, and price match the expected values.

4. Test: givenInvalidProductId_whenGetProductById_thenReturnEmpty

@Test
public void givenInvalidProductId_whenGetProductById_thenReturnEmpty() throws Exception{
    long productId = 1L;
    given(productService.getProductById(productId)).willReturn(Optional.empty());

    ResultActions response = mockMvc.perform(get("/api/products/{id}", productId));

    response.andExpect(status().isNotFound())
            .andDo(print());
}

Explanation:

  • Purpose: This test checks if the GET /api/products/{id} endpoint returns 404 Not Found when provided with an invalid product ID.
  • Given: The productService.getProductById method is mocked to return an empty Optional.
  • When: A GET request is sent to /api/products/{id} with a non-existent ID.
  • Then: The test verifies that the response status is 404 Not Found.

5. Test: givenUpdatedProduct_whenUpdateProduct_thenReturnUpdatedProductObject

@Test
public void givenUpdatedProduct_whenUpdateProduct_thenReturnUpdatedProductObject() throws Exception{
    long productId = 1L;
    Product savedProduct = Product.builder()
            .name("Phone")
            .description("Smartphone")
            .price(500.0)
            .build();
    Product updatedProduct = Product.builder()
            .name("Tablet")
            .description("New Tablet")
            .price(300.0)
            .build();
    given(productService.getProductById(productId)).willReturn(Optional.of(savedProduct));
    given(productService.updateProduct(any(Product.class)))
            .willAnswer((invocation)-> invocation.getArgument(0));

    ResultActions response = mockMvc.perform(put("/api/products/{id}", productId)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(updatedProduct)));

    response.andExpect(status().isOk())
            .andDo(print())
            .andExpect(jsonPath("$.name", is(updatedProduct.getName())))
            .andExpect(jsonPath("$.description", is(updatedProduct.getDescription())))
            .andExpect(jsonPath("$.price", is(updatedProduct.getPrice())));
}

Explanation:

  • Purpose: This test verifies the PUT /api/products/{id} endpoint for updating a product.
  • Given: A saved product (Phone) and an updatedProduct (Tablet) are created. The service methods getProductById and updateProduct are mocked to return the product.
  • When: A PUT request is sent with the updated product data.
  • Then: The test checks if the status is 200 OK and verifies the updated product's details.

6. Test: givenUpdatedProduct_whenUpdateProduct_thenReturn404

@Test
public void givenUpdatedProduct_whenUpdateProduct_thenReturn404() throws Exception{
    long productId = 1L;
    Product updatedProduct = Product.builder()
            .name("Tablet")
            .description("New Tablet")
            .price(300.0)
            .build();
    given(productService.getProductById(productId)).willReturn(Optional.empty());

    ResultActions response = mockMvc.perform(put("/api/products/{id}", productId)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(updatedProduct)));

    response.andExpect(status().isNotFound())
            .andDo(print());
}

Explanation:

  • Purpose: This test verifies the PUT /api/products/{id} endpoint when trying to update a non-existent product.
  • Given: The productService.getProductById method is mocked to return an empty Optional.
  • When: A PUT request is sent to update a product with an invalid ID.
  • Then: The test asserts that the response status is 404 Not Found.

7. Test: givenProductId_whenDeleteProduct_thenReturn200

@Test
public void givenProductId_whenDeleteProduct_thenReturn200() throws Exception{
    long productId = 1L;
    willDoNothing().given(productService).deleteProduct(productId);

    ResultActions response = mockMvc.perform(delete("/api/products/{id}", productId));

    response.andExpect(status().isOk())
            .andDo(print());
}

Explanation:

  • Purpose: This test verifies the DELETE /api/products/{id} endpoint for deleting a product.
  • Given: The productService.deleteProduct method is mocked to do nothing.
  • When: A DELETE request is sent to delete a product by its ID.
  • Then: The test checks if the status is 200 OK, indicating that the product was deleted successfully.

Conclusion

In this tutorial, we learned how to perform unit testing on Spring Boot CRUD RESTful web services using JUnit 5 and the Mockito framework. We used MockMvc to call HTTP requests to the web layer (controllers) in Spring Boot applications without starting a server.

Comments