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.
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
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
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
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
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 andproductService.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 andproductService.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 returns404 Not Found
when provided with an invalid product ID. - Given: The
productService.getProductById
method is mocked to return an emptyOptional
. - 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 anupdatedProduct
(Tablet
) are created. The service methodsgetProductById
andupdateProduct
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 emptyOptional
. - 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
MockMvc
to call HTTP requests to the web layer (controllers) in Spring Boot applications without starting a server.
Comments
Post a Comment
Leave Comment