A Complete Guide to Testing a REST API With Spring

Hi everyone! In this new tutorial, I'll be going over my complete guide to testing a REST API using Spring Framework. This includes unit testing each layer (i.e., controllers, services, and dao layers) along with integration testing the entire API by sending real HTTP requests to the endpoints.
Prerequisites
To follow along with this guide you need to know the following:
- How to build a simple REST API with Spring Framework
- Basic software testing concepts such as unit testing, mocking, and integration testing
- How to write simple tests with JUnit and Mockito
Also note that throughout the code snippets, we'll be using a lot of static imports as a bit of "syntactic sugar" for the readability of our tests (as commonly done using these test frameworks). If you're ever confused about where a method is from, it's probably from a static import omitted from the code snippet for simplicity. At the end of each section, I'll share the full code for each test class so you can see the static imports.
The Application We Will Be Testing
For this guide, we'll be testing a simple REST API for managing grocery products. Below are the classes of this application that we'll be testing:
ProductController
package io.john.amiscaray.productapi.controller;
import io.john.amiscaray.productapi.dto.ProductDTO;
import io.john.amiscaray.productapi.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Set;
@RestController
@RequestMapping("products")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ResponseEntity<Set<ProductDTO>> getAllProducts() {
return ResponseEntity.ok(productService.getAllProducts());
}
@GetMapping("{id}")
public ResponseEntity<ProductDTO> getProductById(@PathVariable Long id) {
return ResponseEntity.ok(productService.getProductById(id));
}
@GetMapping("apples")
public ResponseEntity<Set<ProductDTO>> getAppleProducts() {
return ResponseEntity.ok(productService.getAppleProducts());
}
@PostMapping
public ResponseEntity<Void> createProduct(@RequestBody ProductDTO productDTO) {
var newProductID = productService.createProduct(productDTO);
return ResponseEntity.created(URI.create("/products/" + newProductID))
.build();
}
@DeleteMapping("{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
ControllerErrorHandler
package io.john.amiscaray.productapi.controller;
import lombok.extern.java.Log;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.NoSuchElementException;
@ControllerAdvice
@Log
public class ControllerErrorHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGenericException(Exception e) {
log.severe(e.getMessage());
return new ResponseEntity<>("Unexpected server error", HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<String> handleNoResourceFoundException(NoResourceFoundException e) {
return ResponseEntity.notFound().build();
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<String> handleNoSuchElementException(NoSuchElementException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.NOT_FOUND);
}
}
ProductRepository
package io.john.amiscaray.productapi.dao;
import io.john.amiscaray.productapi.data.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Set;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Set<Product> findAllByDescriptionContainsIgnoreCase(String substring);
}
ProductService
package io.john.amiscaray.productapi.service;
import io.john.amiscaray.productapi.dao.ProductRepository;
import io.john.amiscaray.productapi.data.Product;
import io.john.amiscaray.productapi.dto.ProductDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ProductService {
private final ProductRepository productRepository;
@Autowired
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Set<ProductDTO> getAllProducts() {
return productRepository.findAll()
.stream()
.map(ProductDTO::new)
.collect(Collectors.toSet());
}
public ProductDTO getProductById(Long id) {
return new ProductDTO(productRepository.findById(id)
.orElseThrow());
}
public Set<ProductDTO> getAppleProducts() {
return productRepository.findAllByDescriptionContainsIgnoreCase("Apple")
.stream()
.map(ProductDTO::new)
.collect(Collectors.toSet());
}
public Long createProduct(ProductDTO productDTO) {
var detachedProduct = new Product(productDTO);
var savedProduct = productRepository.save(detachedProduct);
return savedProduct.getId();
}
public void deleteProduct(Long id) {
if (!productRepository.existsById(id)) {
throw new NoSuchElementException("Could not find product with id: " + id);
}
productRepository.deleteById(id);
}
}
Unit Testing The Controller Layer
First, let's begin by testing the controller layer (i.e., the ProductController). Our goal for this is to create a light-weight unit test class. Thus, we should mock all dependencies of our controller and test it without actually sending any HTTP requests. To do this, first, we need to figure out the test setup to make this possible.
Test Setup
To begin, let's define our test class. We'll annotate it with @ExtendWith(MockitoExtension.class) to allow us to use Mockito's annotation functionality. As for its fields, we'll need a ProductController along with the ProductService it depends on. Using Mockito's annotations, we can inject a mock for our ProductService and inject it into a constructor for a new ProductController:
@ExtendWith(MockitoExtension.class)
public class ProductControllerTest {
@InjectMocks // Creates a new ProductController with the mock ProductService added to the constructor
private ProductController productController;
@Mock
private ProductService productService;
}
From there, we'll also need to set up a MockMvc instance to help us mock HTTP requests for our tests. Using the static MockMvcBuilders#standaloneSetup method, we can set up MockMvc to work with only our ProductController and our ControllerErrorHandler for when we test error conditions:
@ExtendWith(MockitoExtension.class)
public class ProductControllerTest {
@InjectMocks
private ProductController productController;
@Mock
private ProductService productService;
private MockMvc mockMvc;
@BeforeEach
public void setUp() {
ControllerErrorHandler controllerErrorHandler = new ControllerErrorHandler();
mockMvc = MockMvcBuilders.standaloneSetup(productController, controllerErrorHandler)
.build();
}
}
Note that when using the
MockMvcBuilders#standaloneSetupmethod, only the controllers added can be used. If we had another controller in our project, we wouldn't be able to mock HTTP requests to it using thisMockMvcinstance.
Writing Our First Test
Now, let's write our first test for this class to demonstrate how we can use MockMVC to write a semantic test mocking an HTTP request:
@Test
public void whenRetrievingAllProductsThenExpectListOfProducts() throws Exception {
var mockProducts = Set.of(new ProductDTO(1L, "Apple", "Fresh Apples", 5.0f));
when(productService.getAllProducts())
.thenReturn(mockProducts);
mockMvc.perform(get("/products"))
.andExpect(content().json(new ObjectMapper().writeValueAsString(mockProducts)));
}
First, we define a set containing the mock products that will be the returned from our ProductService. With Mockito, we can then make our mock ProductService return this set of products using the Mockito#when method. Next, MockMvc allows us to create a method chaining pattern to semantically send a mock HTTP request and assert the expected response. Here, we are sending a mock GET request using the MockMvc#perform method and with the MockMvcRequestBuilders#get method we specify the GET request to be to the "/products" endpoint. Finally, by calling the MockMvc#andExpect method, we assert that the response should be a JSON string matching the mockProducts set serialized as JSON.
Writing Tests For Other GET Requests
From there, we can write similarly structured tests for other GET endpoints:
@Test
public void whenRetrievingAppleProductsThenExpectListOfAppleRelatedProducts() throws Exception {
var mockProducts = Set.of(
new ProductDTO(1L, "Apple", "Fresh Apples", 5.0f),
new ProductDTO(2L, "Apple Pie", "Fresh Apple Pie", 15.0f),
new ProductDTO(3L, "Apple turnover", "Fresh pastries", 8.0f)
);
when(productService.getAppleProducts())
.thenReturn(mockProducts);
mockMvc.perform(get("/products/apples"))
.andExpect(content().json(new ObjectMapper().writeValueAsString(mockProducts)));
}
@Test
public void whenRetrievingProductByIdThenExpectProduct() throws Exception {
var mockProduct = new ProductDTO(1L, "Oranges", "Fresh Oranges", 5.0f);
when(productService.getProductById(1L))
.thenReturn(mockProduct);
mockMvc.perform(get("/products/1"))
.andExpect(content().json(new ObjectMapper().writeValueAsString(mockProduct)));
}
Asserting Status Codes
With MockMVC we can also assert the expected HTTP response status code. As a perfect example of this, we can test for a 404 error:
@Test
public void whenRetrievingProductByIdThatDoesNotExistThenExpectProductNotFound() throws Exception {
when(productService.getProductById(1L))
.thenThrow(NoSuchElementException.class);
mockMvc.perform(get("/products/1"))
.andExpect(status().isNotFound());
}
Final Test Class
With that, we can share the final test class including some additional tests for full test coverage:
package io.john.amiscaray.productapi.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.john.amiscaray.productapi.dto.ProductDTO;
import io.john.amiscaray.productapi.service.ProductService;
import org.junit.jupiter.api.BeforeEach;
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 org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.NoSuchElementException;
import java.util.Set;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(MockitoExtension.class)
public class ProductControllerTest {
@InjectMocks
private ProductController productController;
@Mock
private ProductService productService;
private MockMvc mockMvc;
@BeforeEach
public void setUp() {
ControllerErrorHandler controllerErrorHandler = new ControllerErrorHandler();
mockMvc = MockMvcBuilders.standaloneSetup(productController, controllerErrorHandler)
.build();
}
@Test
public void whenRetrievingAllProductsThenExpectListOfProducts() throws Exception {
var mockProducts = Set.of(new ProductDTO(1L, "Apple", "Fresh Apples", 5.0f));
when(productService.getAllProducts())
.thenReturn(mockProducts);
mockMvc.perform(get("/products"))
.andExpect(content().json(new ObjectMapper().writeValueAsString(mockProducts)));
}
@Test
public void whenRetrievingAppleProductsThenExpectListOfAppleRelatedProducts() throws Exception {
var mockProducts = Set.of(
new ProductDTO(1L, "Apple", "Fresh Apples", 5.0f),
new ProductDTO(2L, "Apple Pie", "Fresh Apple Pie", 15.0f),
new ProductDTO(3L, "Apple turnover", "Fresh pastries", 8.0f)
);
when(productService.getAppleProducts())
.thenReturn(mockProducts);
mockMvc.perform(get("/products/apples"))
.andExpect(content().json(new ObjectMapper().writeValueAsString(mockProducts)));
}
@Test
public void whenRetrievingProductByIdThenExpectProduct() throws Exception {
var mockProduct = new ProductDTO(1L, "Oranges", "Fresh Oranges", 5.0f);
when(productService.getProductById(1L))
.thenReturn(mockProduct);
mockMvc.perform(get("/products/1"))
.andExpect(content().json(new ObjectMapper().writeValueAsString(mockProduct)));
}
@Test
public void whenRetrievingProductByIdThatDoesNotExistThenExpectProductNotFound() throws Exception {
when(productService.getProductById(1L))
.thenThrow(NoSuchElementException.class);
mockMvc.perform(get("/products/1"))
.andExpect(status().isNotFound());
}
@Test
public void whenDeletingProductThenExpectNoContentResponse() throws Exception {
mockMvc.perform(delete("/products/1"))
.andExpect(status().isNoContent());
}
@Test
public void whenDeletingProductThatDoesNotExistThenExpectProductNotFound() throws Exception {
doThrow(NoSuchElementException.class)
.when(productService).deleteProduct(1L);
mockMvc.perform(delete("/products/1"))
.andExpect(status().isNotFound());
}
}
Testing Our ProductService
Now, going down another layer, we can test our ProductService. Similar to the controller class, we can use the Mockito annotations to mock our dependencies (the ProductRepository) and inject these mock dependencies into a new instance of our ProductService:
@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
@InjectMocks
private ProductService productService;
@Mock
private ProductRepository productRepository;
}
Writing Our Tests
Writing our tests for the ProductService should be pretty straightforward. This service class deals with simple CRUD operations on products and only has ProductRepository as a dependency. Unlike the ProductController, we don't need to use anything fancy like MockMvc to mock complicated operations like HTTP communication. Thus, all of our tests can follow a simple structure where we have a given behavior of our ProductRepository, perform some action with our service, and then assert the result of the operation. Below I'll give the final test class for our ProductService and explain the few parts that might be confusing:
package io.john.amiscaray.productapi.service;
import io.john.amiscaray.productapi.dao.ProductRepository;
import io.john.amiscaray.productapi.data.Product;
import io.john.amiscaray.productapi.dto.ProductDTO;
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.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
@InjectMocks
private ProductService productService;
@Mock
private ProductRepository productRepository;
@Test
public void whenGetAllProductsExpectAllProductsAsDTOs() {
// Given
var sampleProducts = List.of(
new Product("Apple Pie", "Fresh Apple Pie", 15.0f),
new Product("Oranges", "Fresh Oranges", 6.0f),
new Product("Bananas", "Fresh Bananas", 6.0f)
);
when(productRepository.findAll()).thenReturn(sampleProducts);
// When
var productDTOs = productService.getAllProducts();
// Assert
assertThat(productDTOs, equalTo(sampleProducts.stream().map(ProductDTO::new).collect(Collectors.toSet())));
}
@Test
public void whenLoadProductByIDExpectProductAsDTO() {
// Given
var sampleProduct = new Product("Raspberries", "Fresh Raspberries", 6.0f);
when(productRepository.findById(1L)).thenReturn(Optional.of(sampleProduct));
// When
var productDTO = productService.getProductById(1L);
// Assert
assertThat(productDTO, equalTo(new ProductDTO(sampleProduct)));
}
@Test
public void whenGetAppleProductExpectProductsWithAppleSubstring() {
// Given
var sampleProducts = Set.of(
new Product("Apples", "Fresh Apples", 6.0f),
new Product("Apple Pie", "Fresh Apple Pie", 15.0f)
);
when(productRepository.findAllByDescriptionContainsIgnoreCase("Apple"))
.thenReturn(sampleProducts);
// When
var appleProducts = productService.getAppleProducts();
// Assert
assertThat(appleProducts, equalTo(sampleProducts.stream().map(ProductDTO::new).collect(Collectors.toSet())));
}
@Test
public void whenCreateProductExpectIDOfNewProduct() {
var newProduct = new ProductDTO("Bananas", "Fresh Bananas", 6.0f);
var savedProduct = new Product(newProduct);
savedProduct.setId(1L);
when(productRepository.save(any()))
.thenReturn(savedProduct);
var productID = productService.createProduct(newProduct);
assertThat(productID, equalTo(savedProduct.getId()));
}
@Test
public void whenDeleteProductByIDExpectRepositoryDeleteByIDIsCalled() {
when(productRepository.existsById(1L))
.thenReturn(true);
productService.deleteProduct(1L);
verify(productRepository).deleteById(1L);
}
@Test
public void whenDeleteProductByIDThatDoesNotExistExpectNoSuchElementExceptionAndRepositoryNotCalled() {
when(productRepository.existsById(1L))
.thenReturn(false);
assertThrows(NoSuchElementException.class, () -> productService.deleteProduct(1L));
verify(productRepository, never()).deleteById(1L);
}
}
From the above class, I have a few notes about things that might be of interest to you:
- Many of our classes use the Hamcrest library for their assertions. This allows us to make semantic assertions mainly using the
assertThatandequalTomethods. There are many other ways you can use this library for semantic assertions if you wish to explore it further. - Some of our tests use the
Mockito#verifymethod for their assertions. We can invoke methods of ourProductRepositoryclass on the return value of this method to assert whether or not the invoked method gets called on our mock at any point. For instance, in thewhenDeleteProductByIDExpectRepositoryDeleteByIDIsCalledmethod, we assert that the method gets called at least once while in thewhenDeleteProductByIDThatDoesNotExistExpectNoSuchElementExceptionAndRepositoryNotCalledmethod we assert that it never gets called. - In the
whenCreateProductExpectIDOfNewProductmethod, we use theArgumentMatchers.anymethod to enforce that when we pass any value to thesavemethod of our mockProductRepository, it will return the given product.
Testing Our ProductRepository
After testing our service, our next step is the ProductRepository. With that, the setup gets slightly more involved than when we were testing the ProductService.
Test Configuration
First, we need to set up a mock database which we will later populate before starting our tests. In our test/java/resources folder, we need to add a application-test.properties file with configuration for our test database:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.tool.hbm2ddl=TRACE
These properties will be activated when setting a test Spring profile.
Setting Up ProductRepositoryTest
From there, we can set up our ProductRepositoryTest class:
@DataJpaTest
@ActiveProfiles("test")
public class ProductRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ProductRepository productRepository;
private List<Product> products;
}
Here, we use the @DataJpaTest annotation to set up the Spring Context with all the JPA Repositories, and the @ActiveProfiles annotation to activate our test profile with the above configuration. From there, we auto-wire a TestEntityManager for populating our test database and a ProductRepository we are about to test. Lastly, we declare a products list of Product entities that we can add to the test database for our tests like so:
@BeforeEach
public void setUpDataSet() {
products = List.of(
new Product("Apples", "Fresh Apples", 6.0f),
new Product("Oranges", "Fresh Oranges", 6.0f),
new Product("Bananas", "Fresh Bananas", 6.0f),
new Product("Apple Pie", "Fresh Apple Pie", 15.0f),
new Product("Raspberries", "Fresh Raspberries", 6.0f),
new Product("Apple Tarts", "Apple Pastries", 10.0f)
);
for (var product : products) {
entityManager.persist(product);
}
entityManager.flush();
}
You may be wondering why we are saving these products before every test and if that will result in many duplicate rows but with different IDs. With the TestEntityManager, the database should be reset back to its initial state after each test, i.e., all the data will be reset. Thus, using this setup, we should have the same database state at the start of every test.
With that, we can write a couple simple tests for retrieving data with our repository:
package io.john.amiscaray.productapi.dao;
import io.john.amiscaray.productapi.data.Product;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
@DataJpaTest
@ActiveProfiles("test")
public class ProductRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ProductRepository productRepository;
private List<Product> products;
@BeforeEach
public void setUpDataSet() {
products = List.of(
new Product("Apples", "Fresh Apples", 6.0f),
new Product("Oranges", "Fresh Oranges", 6.0f),
new Product("Bananas", "Fresh Bananas", 6.0f),
new Product("Apple Pie", "Fresh Apple Pie", 15.0f),
new Product("Raspberries", "Fresh Raspberries", 6.0f),
new Product("Apple Tarts", "Apple Pastries", 10.0f)
);
for (var product : products) {
entityManager.persist(product);
}
entityManager.flush();
}
@Test
public void whenFindAllProductsExpectListOfAllProducts() {
var foundProducts = productRepository.findAll();
assertThat(foundProducts, equalTo(products));
}
@Test
public void whenFindAllByDescriptionContainsAppleIgnoreCaseExpectProductsWithAppleSubstring() {
var foundProducts = productRepository.findAllByDescriptionContainsIgnoreCase("Apple");
assertThat(foundProducts, equalTo(
products.stream()
.filter(product -> product.getName().toLowerCase().contains("apple"))
.collect(Collectors.toSet())
));
}
}
Since much of the work of implementing CRUD operations in our repository gets done for us by Spring, I think it's fine to leave the tests at that.
Integration Testing Our ProductController
Finally, we can put all the pieces together and write integration tests for our ProductController. This will send actual HTTP requests to our ProductController and go down through the ProductService, ProductRepository, and test database.
Setting Up Our Integration Test Class
First, let's set up the test class for our integration tests:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class ProductControllerIT {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ProductRepository productRepository;
private List<Product> products;
}
Here, we use the @SpringBootTest annotation to set up our Spring Boot application on a random port and the @ActiveProfiles annotation to activate our test profile to use the same database as our ProductRepository tests. From there, we can define an int field annotated with @LocalServerPort to get the random port. Also, beneath that, we have an auto-wired TestRestTemplate to send HTTP requests, an auto-wired ProductRepository that we'll use to set the initial data, and a list of products we'll use as the data for the tests. From there, let's write a @BeforeEach method setting up our test data:
@BeforeEach
public void setUpDataSet() {
productRepository.deleteAll();
products = List.of(
new Product("Apples", "Fresh Apples", 6.0f),
new Product("Oranges", "Fresh Oranges", 6.0f),
new Product("Bananas", "Fresh Bananas", 6.0f),
new Product("Apple Pie", "Fresh Apple Pie", 15.0f),
new Product("Raspberries", "Fresh Raspberries", 6.0f),
new Product("Apple Tarts", "Apple Pastries", 10.0f)
);
productRepository.saveAll(products);
}
Afterward, we can write our first test for sending a GET request for all the products:
@Test
public void whenGetAllProductsExpectOkResponseWithListOfProducts() {
var url = "http://localhost:" + port + "/products";
ResponseEntity<Set<ProductDTO>> productsResponse = restTemplate.exchange(
url,
HttpMethod.GET,
null,
new ParameterizedTypeReference<Set<ProductDTO>>() { }
);
assertThat(productsResponse.getStatusCode(), equalTo(HttpStatus.OK));
assertThat(productsResponse.getBody(), equalTo(products.stream().map(ProductDTO::new).collect(Collectors.toSet())));
}
Using the TestRestTemplate#exchange method, we can send an HTTP GET request to the /products endpoint. The third argument to that method would be a RequestEntity where we can specify a body if needed (we purposely kept this null since we don't need a body) and the fourth argument is the type of the expected Response. Since the expected type of the response has a type parameter, we need to use a ParameterizedTypeReference to represent the type. After that, we can make assertions on the returned ResponseEntity<Set<ProductDTO>> which contains all the HTTP response information. With that, I can show you the full integration test class and then explain (in list form) any potentially interesting or confusing details about it to you:
package io.john.amiscaray.productapi.controller;
import io.john.amiscaray.productapi.dao.ProductRepository;
import io.john.amiscaray.productapi.data.Product;
import io.john.amiscaray.productapi.dto.ProductDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class ProductControllerIT {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ProductRepository productRepository;
private List<Product> products;
@BeforeEach
public void setUpDataSet() {
productRepository.deleteAll();
products = List.of(
new Product("Apples", "Fresh Apples", 6.0f),
new Product("Oranges", "Fresh Oranges", 6.0f),
new Product("Bananas", "Fresh Bananas", 6.0f),
new Product("Apple Pie", "Fresh Apple Pie", 15.0f),
new Product("Raspberries", "Fresh Raspberries", 6.0f),
new Product("Apple Tarts", "Apple Pastries", 10.0f)
);
productRepository.saveAll(products);
}
@Test
public void whenGetAllProductsExpectOkResponseWithListOfProducts() {
var url = "http://localhost:" + port + "/products";
ResponseEntity<Set<ProductDTO>> productsResponse = restTemplate.exchange(
url,
HttpMethod.GET,
null,
new ParameterizedTypeReference<Set<ProductDTO>>() { }
);
assertThat(productsResponse.getStatusCode(), equalTo(HttpStatus.OK));
assertThat(productsResponse.getBody(), equalTo(products.stream().map(ProductDTO::new).collect(Collectors.toSet())));
}
@Test
public void whenGetProductByIdExpectOkResponseWithProduct() {
var url = "http://localhost:" + port + "/products/" + products.getFirst().getId();
ResponseEntity<ProductDTO> productResponse = restTemplate.getForEntity(url, ProductDTO.class);
assertThat(productResponse.getStatusCode(), equalTo(HttpStatus.OK));
assertThat(productResponse.getBody(), equalTo(new ProductDTO(products.getFirst())));
}
@Test
public void whenGetAppleProductsExpectOkResponseWithProductsWithAppleSubstring() {
var url = "http://localhost:" + port + "/products/apples";
ResponseEntity<Set<ProductDTO>> productsResponse = restTemplate.exchange(
url,
HttpMethod.GET,
null,
new ParameterizedTypeReference<Set<ProductDTO>>() { }
);
assertThat(productsResponse.getStatusCode(), equalTo(HttpStatus.OK));
assertThat(productsResponse.getBody(), equalTo(products.stream()
.filter(product -> product.getName().toLowerCase().contains("apple"))
.map(ProductDTO::new)
.collect(Collectors.toSet())));
}
@Test
public void whenPostProductExpectCreatedResponseAndLocationHeader() {
var url = "http://localhost:" + port + "/products";
ResponseEntity<Void> saveResponse = restTemplate.exchange(
url,
HttpMethod.POST,
new HttpEntity<>(new ProductDTO("Avocado", "Fresh Avocado", 12.0f)),
Void.class
);
assertThat(saveResponse.getStatusCode(), equalTo(HttpStatus.CREATED));
assertThat(saveResponse.getHeaders().getLocation().getPath(), matchesPattern("/products/[1-9][0-9]*"));
}
@Test
public void whenDeleteProductExpectNoContentResponseAndProductDeleted() {
var productID = products.getFirst().getId();
var url = "http://localhost:" + port + "/products/" + productID;
ResponseEntity<Void> saveResponse = restTemplate.exchange(
url,
HttpMethod.DELETE,
null,
Void.class
);
assertThat(saveResponse.getStatusCode(), equalTo(HttpStatus.NO_CONTENT));
assertThat(productRepository.existsById(productID), is(false));
}
}
- In the
whenGetProductByIdExpectOkResponseWithProductwe have an example of how you can use theTestRestTemplate#getForEntitymethod to send a simpleGETrequest. We weren't able to use this for thewhenGetAllProductsExpectOkResponseWithListOfProductsbecause of the need to specify aParameterizedTypeReference - In the
whenPostProductExpectCreatedResponseAndLocationHeadermethod you can see how we can make assertions on an HTTP header. I also made use of another Hamcrest assertion method I haven't shown yet to match a string against a regex pattern. - In
whenDeleteProductExpectNoContentResponseAndProductDeleted, I asserted the state of theProductRepositoryto verify the proper interaction between components as is done in integration tests.
Conclusion
With that, we have successfully unit and integration tested a REST API using Spring Framework, JUnit, Hamcrest, and Mockito. In doing so, we went over all the Spring application layers ensuring great test coverage while creating semantically sound tests that should be highly maintainable. I sincerely hope this guide helps you create awesome tests for your future Spring projects!




