Skip to main content

Command Palette

Search for a command to run...

A Complete Guide to Testing a REST API With Spring

Updated
14 min read
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#standaloneSetup method, 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 this MockMvc instance.

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:

  1. Many of our classes use the Hamcrest library for their assertions. This allows us to make semantic assertions mainly using the assertThat and equalTo methods. There are many other ways you can use this library for semantic assertions if you wish to explore it further.
  2. Some of our tests use the Mockito#verify method for their assertions. We can invoke methods of our ProductRepository class 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 the whenDeleteProductByIDExpectRepositoryDeleteByIDIsCalled method, we assert that the method gets called at least once while in the whenDeleteProductByIDThatDoesNotExistExpectNoSuchElementExceptionAndRepositoryNotCalled method we assert that it never gets called.
  3. In the whenCreateProductExpectIDOfNewProduct method, we use the ArgumentMatchers.any method to enforce that when we pass any value to the save method of our mock ProductRepository, 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));
    }

}
  1. In the whenGetProductByIdExpectOkResponseWithProduct we have an example of how you can use the TestRestTemplate#getForEntity method to send a simple GET request. We weren't able to use this for the whenGetAllProductsExpectOkResponseWithListOfProducts because of the need to specify a ParameterizedTypeReference
  2. In the whenPostProductExpectCreatedResponseAndLocationHeader method 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.
  3. In whenDeleteProductExpectNoContentResponseAndProductDeleted, I asserted the state of the ProductRepository to 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!

Learn Java

Part 2 of 3

In this series, I will teach you some important or cool Java knowledge from my years of experience learning Java, building large-scale projects, and from the industry.

Up next

Metaprogramming in Java: The Power of Reflection and Annotations

How you can use metaprogramming to create Java magic in your future projects.