Testing Spring Boot applications
In this article, we will create and test using both unit and integration tests a small Spring Boot application with a REST API. If you don’t have any experience with JUnit5 and Mockito, you can start with this article.
Tools used: Spring Boot 2, Spring Data JPA, H2 as an in-memory database, Spring Test, Mockito and JUnit5.
Spring Boot’s starter dependency named Test comes with a lot of functionality and libraries such as JUnit5, Mockito, Hamcrest, AssertJ, JSONAssert, JSONPath that help us write great tests.
This example does not focus on code coverage.
ProductController.java
@RestController
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping(value = "/status")
public String checkStatus(){
return "Live!";
}
@GetMapping("/products")
public Product getProduct(@RequestParam Long id){
boolean discount = true;
return productService.getProductById(id, discount);
}
}
ProductService.java
public interface ProductService {
Product getProductById(Long id, boolean hasDiscount);
}
ProductServiceImpl.java
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductRepository productRepository;
public Product getProductById(Long id, boolean hasDiscount) {
Product product = productRepository.getProductById(id);
if (product != null) {
if (hasDiscount) {
product.setPrice(product.getPrice() - 10);
}
return product;
} else {
throw new ProductNotFoundException();
}
}
}
ProductRepository.java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Product getProductById(Long id);
}
Product.java
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
private Double price;
private Long quantity;
private String storeName;
//constructors, getters and setters
}
Controller Unit tests
REST Controllers are a key part of a Spring Boot REST application and Spring Boot comes with utility objects to help us unit test them in isolation from the rest of the application.
In this test, we will use @ExtendWIth(SpringExtension.class) annotation to register the test class as a Spring Unit Test and @WebMvcTest(ProductController.class) annotation that will enable us to write a Spring MVC test that focuses only on Spring MVC components.
@WebMvcTest(ProductController.class) will also disable full auto-configuration and instead apply only configuration relevant to MVC tests. Without other components like Services or Repositories in ApplicationContext, we will test the controller in isolation.
MockMvc component is provided by Spring to make calls to the Spring MVC API and assert different properties like status code and received response.
@ExtendWith(SpringExtension.class)
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productServiceImpl; // This will mock a Spring Bean and Inject it where is needed
// This test uses assertEquals to check the validity of the response
@Test
void checkStatus_Should_ReturnLive_When_StatusPathIsCalled_AssertUsingAssertEquals() throws Exception {
//build request, execute GET to /status
MvcResult mvcResult = mockMvc
.perform(MockMvcRequestBuilders.get("/status").accept(MediaType.APPLICATION_JSON))
.andReturn();
assertEquals("Live!", mvcResult.getResponse().getContentAsString());
}
// This test uses ResultMatchers to check the validity of the response
@Test
void checkStatus_Should_ReturnLive_When_StatusPathIsCalled_AssertUsingResultMatchers() throws Exception {
//build request, execute GET to /status and assert result using Response Matchers
mockMvc.perform(MockMvcRequestBuilders.get("/status").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) //check is response status is 200
.andExpect(content().string("Live!"))
.andReturn();
}
// This test uses ResultMatchers with JSONAssert to check the validity of the response
@Test
void getProduct_Should_ReturnString_When_ProductsPathIsCalled_AssertUsingResultMatchers() throws Exception {
String expectedResult = "{\"id\":1,\"name\":\"Cheese\",\"price\":10.0,\"quantity\":100}";
String expectedResultWithoutSomePropertiesAndEscapeChars = "{id: 1, name: Cheese, price: 10.0}";
when(productServiceImpl.getProductById(1L, true))
.thenReturn(new Product(1L, "Cheese", 10.0, 100L));
// build request, execute GET to /product and assert result using Response Matchers with JSONAssert
mockMvc.perform(MockMvcRequestBuilders.get("/products?id=1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) //check is response status is 200
.andExpect(content().json(expectedResult)) //it will succeed even if a property will be missing
.andExpect(content().json(expectedResultWithoutSomePropertiesAndEscapeChars))
.andReturn();
// behind the scene .andExpect(content().json(...)) uses JSONAssert calling assertEquals
// with strict mode deactivated
verify(productServiceImpl, times(1)).getProductById(1L, true);
}
// This test shows some of the capabilities of JSONAssert to check the validity of the response
@Test
void getProduct_Should_ReturnString_When_ProductsPathIsCalled_AssertUsingJSONAssert() throws Exception {
String expectedResult = "{\"id\":1,\"name\":\"Cheese\",\"price\":10.0,\"quantity\":100,\"storeName\":null}";
String expectedResultWithoutSomePropertiesAndEscapeChars = "{id:1,name:Cheese,price:10.0}";
when(productServiceImpl.getProductById(anyLong(), anyBoolean()))
.thenReturn(new Product(1L, "Cheese", 10.0, 100L));
// build request, execute GET to /products
MvcResult mvcResult = mockMvc
.perform(MockMvcRequestBuilders.get("/products?id=1").accept(MediaType.APPLICATION_JSON))
.andReturn();
// strict mode, everything should match, the structure should be the same
JSONAssert.assertEquals(expectedResult, mvcResult.getResponse().getContentAsString(), true);
// strict mode off, properties may be missing, escape characters can be missing too
JSONAssert.assertEquals(expectedResultWithoutSomePropertiesAndEscapeChars,
mvcResult.getResponse().getContentAsString(), false);
verify(productServiceImpl, times(1)).getProductById(anyLong(), anyBoolean());
}
}
Service Unit tests
Services are the most important layer of our application because they encapsulate the whole business logic. Tests for services should be concise, should cover all situations, therefore, they should have the coverage as close as possible to 100%.
We can test services outside Spring Context, mocking every dependency and testing them like regular Java classes rather than Spring Beans.
@ExtendWith(MockitoExtension.class)
public class ProductSeviceTest {
@InjectMocks
private ProductService productService = new ProductServiceImpl();
@Mock
private ProductRepository productRepository;
@Test
void getProductById_Should_ReturnProduct_When_ParametersAreValidAndDiscountIsApplied(){
when(productRepository.getProductById(any())).thenReturn(new Product(1L, "Beer", 100.0, 100L));
Product product = productService.getProductById(1L, true);
assertEquals("Beer", product.getName());
assertEquals(90.0, product.getPrice());
verify(productRepository, times(1)).getProductById(any());
}
@Test
void getProductById_Should_ReturnProduct_When_ParametersAreValidAndDiscountIsNotApplied(){
when(productRepository.getProductById(any())).thenReturn(new Product(1L, "Beer", 100.0, 100L));
Product product = productService.getProductById(1L, false);
assertEquals("Beer", product.getName());
assertEquals(100.0, product.getPrice());
verify(productRepository, times(1)).getProductById(any());
}
@Test
void getProductById_Should_ThrowException_When_ProductIsNotFound(){
assertThrows(ProductNotFoundException.class, () -> {
when(productRepository.getProductById(any())).thenReturn(null);
Product product = productService.getProductById(1L, true);
});
}
}
Repository Unit tests
It’s not always needed to write unit tests for repository methods. There are cases when you only use methods that code from JPA like findAll() and it would be useless to test them.
We want to write tests only for custom queries.
Repository tests are a special category, Spring gives us some tools to help us write them. First, we can mark a test as a Spring Unit test with @ExtendWith(SpringExtension.class) and after we can use @DataJpaTest annotation to tell Spring to create an in-memory database for us just for the purpose of testing the repository.
@ExtendWith(SpringExtension.class)
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void getProductById_Should_ReturnProduct_When_ProperIdIsProvided() {
productRepository.save(new Product(1L, "Beer", 100.0, 100L));
productRepository.save(new Product(2L, "Cheese", 200.0, 200L));
Product cheese = productRepository.getProductById(2L);
assertEquals(200L, cheese.getQuantity());
}
@Test
void getProductById_Should_ReturnNull_When_ProductIsMissing() {
Product cheese = productRepository.getProductById(2L);
assertEquals(null, cheese);
}
}
Integration tests
Unlike unit tests, integration tests check more components of our application working together.
We tested the Controller, Service and Repository, all in one test, mocking nothing.
Again Spring comes to our rescue and gives us some tools to use. We will use @ExtendWith(SpringExtension.class) annotation to register the test as a Spring Unit test, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) annotation to lunch the entire Spring Boot application on a random port. All components within the application will be instantiated in ApplicationContext simulating the application running, unlike unit tests which test just individual components.
@SpringBootTest annotation will also lunch the in-memory database and query it just like in a real scenario.
Another great tool that Spring provides is the TestRestTemplate object that helps us make API calls to our REST endpoints.
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProductIntegrationTest {
@Autowired
private TestRestTemplate testRestTemplate;
@Autowired
private ProductRepository productRepository; // just to add data in db
@Test
void checkStatusApi_Should_ReturnString_When_Called() {
String response = testRestTemplate.getForObject("/status", String.class);
assertEquals(response, "Live!");
}
@Test
@DirtiesContext //@DirtiesContext will rollback database changes after test is done
void getProductApi_Should_ReturnProduct_When_Called() {
addSomeDataToDb();
ResponseEntity<Product> response = testRestTemplate
.getForEntity("/products?id=1", Product.class, new HashMap<String, String>());
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Beer", response.getBody().getName());
assertEquals(10L, response.getBody().getQuantity());
assertEquals(100, response.getBody().getPrice());
}
private void addSomeDataToDb() {
Product p1 = new Product("Beer", 110.0, 10L);
Product p2 = new Product("Cheese", 100.0, 9L);
Product p3 = new Product("WIne", 110.0, 10L);
productRepository.save(p1);
productRepository.save(p2);
productRepository.save(p3);
}
}
In integration tests, we could mock some components and just inject the mocks in the components we test.
@MockBean
private ProductRepository productRepositoryMock;
Then we can use when() in our tests:
when(productRepositoryMock.method(..)).thenReturn(...)
Conclusion
Writing Spring Boot tests is fast and easy and keeps unwanted bugs away. The framework offers great tools to help us, and we don’t need to add any other dependencies to the project for most of the cases.
The code is available over GitHub.