Why bother writing tests is already a well-discussed topic in software engineering. I won’t go into much details on this topic, but I will mention some of the main benefits.
In my opinion, testing your software is the only way to achieve confidence that your code will work on the production environment. Another huge benefit is that it allows you to refactor your code without fear that you will break some existing features.
In the Java world, one of the most popular frameworks is Spring Boot, and part of the popularity and success of Spring Boot is exactly the topic of this blog – testing. Spring Boot and Spring framework offer out-of-the-box support for testing and new features are being added constantly. When Spring framework appeared on the Java scene in 2005, one of the reasons for its success was exactly this, ease of writing and maintaining tests, as opposed to JavaEE where writing integration requires additional libraries like Arquillian.
In the following, I will go over different types of tests in Spring Boot, when to use them and give a short example.
Testing pyramid
We can roughly group all automated tests into 3 groups:
- Unit tests
- Service (integration) tests
- UI (end to end) tests
As we go from the bottom of the pyramid to the top tests become slower for execution, so if we measure execution times, unit tests will be in orders of few milliseconds, service in hundreds milliseconds and UI will execute in seconds. If we measure the scope of tests, unit as the name suggest test small units of code. Service will test the whole service or slice of that service that involve multiple units and UI has the largest scope and they are testing multiple different services. In the following sections, I will go over some examples and how we can unit test and service test spring boot application. UI testing can be achieved using external tools like Selenium and Protractor, but they are not related to Spring Boot.
Unit testing
In my opinion, unit tests make the most sense when you have some kind of validators, algorithms or other code that has lots of different inputs and outputs and executing integration tests would take too much time. Let’s see how we can test validator with Spring Boot.
Validator class for emails
public class Validators {
private static final String EMAIL_REGEX = "(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])";
public static boolean isEmailValid(String email) {
return email.matches(EMAIL_REGEX);
}
}
Unit tests for email validator with Spring Boot
@RunWith(MockitoJUnitRunner.class)
public class ValidatorsTest {
@Test
public void testEmailValidator() {
assertThat(isEmailValid("valid@north-47.com")).isTrue();
assertThat(isEmailValid("invalidnorth-47.com")).isFalse();
assertThat(isEmailValid("invalid@47")).isFalse();
}
}
MockitoJUnitRunner
is used for using Mockito in tests and detection of @Mock annotations. In this case, we are testing email validator as a separate unit from the rest of the application. MockitoJUnitRunner
is not a Spring Boot annotation, so this way of writing unit tests can be done in other frameworks as well.
Integration testing of the whole application
If we have to choose only one type of test in Spring Boot, then using the integration test to test the whole application makes the most sense. We will not be able to cover all the scenarios, but we will significantly reduce the risk. In order to do integration testing, we need to start the application context. In Spring Boot 2, this is achieved with following annotations @RunWith(SpringRunner.class)
and @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.
RANDOM_PORT
. This will start the application on some random port and we can inject beans into our tests and do REST calls on application endpoints.
In the following is an example code for testing book endpoints. For making rest API calls we are using Spring TestRestTemplate
which is more suitable for integration tests compared to RestTemplate
.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringBootTestingApplicationTests {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private BookRepository bookRepository;
private Book defaultBook;
@Before
public void setup() {
defaultBook = new Book(null, "Asimov", "Foundation", 350);
}
@Test
public void testShouldReturnCreatedWhenValidBook() {
ResponseEntity<Book> bookResponseEntity = this.restTemplate.postForEntity("/books", defaultBook, Book.class);
assertThat(bookResponseEntity.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(bookResponseEntity.getBody().getId()).isNotNull();
assertThat(bookRepository.findById(1L)).isPresent();
}
@Test
public void testShouldFindBooksWhenExists() throws Exception {
Book savedBook = bookRepository.save(defaultBook);
ResponseEntity<Book> bookResponseEntity = this.restTemplate.getForEntity("/books/" + savedBook.getId(), Book.class);
assertThat(bookResponseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(bookResponseEntity.getBody().getId()).isEqualTo(savedBook.getId());
}
@Test
public void testShouldReturn404WhenBookMissing() throws Exception {
Long nonExistingId = 999L;
ResponseEntity<Book> bookResponseEntity = this.restTemplate.getForEntity("/books/" + nonExistingId, Book.class);
assertThat(bookResponseEntity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}
Integration testing of web layer (controllers)
Spring Boot offers the ability to test layers in isolation and only starting the necessary beans that are required for testing. From Spring Boot v1.4 on there is a very convenient annotation @WebMvcTest
that only the required components in order to do a typical web layer test like controllers, Jackson converters and similar without starting the full application context and avoid startup of unnecessary components for this test like database layer. When we are using this annotation we will be making the REST calls with MockMvc
class.
Following is an example of testing the same endpoints like in the above example, but in this case, we are only testing if the web layer is working as expected and we are mocking the database layer using @MockBean
annotation which is also available starting from Spring Boot v1.4. Using these annotations we are only using BookController in the application context and mocking database layer.
@RunWith(SpringRunner.class)
@WebMvcTest(BookController.class)
public class BookControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookRepository repository;
@Autowired
private ObjectMapper objectMapper;
private static final Book DEFAULT_BOOK = new Book(null, "Asimov", "Foundation", 350);
@Test
public void testShouldReturnCreatedWhenValidBook() throws Exception {
when(repository.save(Mockito.any())).thenReturn(DEFAULT_BOOK);
this.mockMvc.perform(post("/books")
.content(objectMapper.writeValueAsString(DEFAULT_BOOK))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andExpect(MockMvcResultMatchers.jsonPath("$.name").value(DEFAULT_BOOK.getName()));
}
@Test
public void testShouldFindBooksWhenExists() throws Exception {
Long id = 1L;
when(repository.findById(id)).thenReturn(Optional.of(DEFAULT_BOOK));
this.mockMvc.perform(get("/books/" + id)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(MockMvcResultMatchers.content().string(Matchers.is(objectMapper.writeValueAsString(DEFAULT_BOOK))));
}
@Test
public void testShouldReturn404WhenBookMissing() throws Exception {
Long id = 1L;
when(repository.findById(id)).thenReturn(Optional.empty());
this.mockMvc.perform(get("/books/" + id)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
}
Integration testing of database layer (repositories)
Similarly to the way that we tested web layer we can test the database layer in isolation, without starting the web layer. This kind of testing in Spring Boot is achieved using the annotation @DataJpaTest
. This annotation will do only the auto-configuration related to JPA layer and by default will use an in-memory database because its fastest to startup and for most of the integration tests will do just fine. We also get access TestEntityManager
which is EntityManager with supporting features for integration tests of JPA.
Following is an example of testing the database layer of the above application. With these tests we are only checking if the database layer is working as expected we are not making any REST calls and we are verifying results from BookRepository
, by using the provided TestEntityManager
.
@RunWith(SpringRunner.class)
@DataJpaTest
public class BookRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private BookRepository repository;
private Book defaultBook;
@Before
public void setup() {
defaultBook = new Book(null, "Asimov", "Foundation", 350);
}
@Test
public void testShouldPersistBooks() {
Book savedBook = repository.save(defaultBook);
assertThat(savedBook.getId()).isNotNull();
assertThat(entityManager.find(Book.class, savedBook.getId())).isNotNull();
}
@Test
public void testShouldFindByIdWhenBookExists() {
Book savedBook = entityManager.persistAndFlush(defaultBook);
assertThat(repository.findById(savedBook.getId())).isEqualTo(Optional.of(savedBook));
}
@Test
public void testFindByIdShouldReturnEmptyWhenBookNotFound() {
long nonExistingID = 47L;
assertThat(repository.findById(nonExistingID)).isEqualTo(Optional.empty());
}
}
Conclusion
You can find a working example with all of these tests on the following repo: https://gitlab.com/47northlabs/public/spring-boot-testing.
In the following table, I’m showing the execution times with the startup of the different types of tests that I’ve used as examples. We can clearly see that unit tests, as mentioned in the beginning, are the fastest ones and that separating integration tests into layered testing leads to faster execution times.
Type of test | Execution time with startup |
Unit test | 80 ms |
Integration test | 620 ms |
Web layer test | 190 ms |
Database layer test | 220 ms |