Spring Boot testing with JUnit 5
JUnit 5 (JUnit Jupiter) is around for quite some time already and it is equipped with tons of features. But surprisingly JUnit 5 it is not a default test library dependency when it comes to the Spring Boot Test Starter: it is still JUnit 4.12, released back in 2014. If you consider using JUnit 5 in you next Spring Boot based project then this blog post is for you. You will learn about the basic setup for Gradle and Maven based projects with examples of Spring Boot tests for different use cases.
Source code
The source code for this article can be found on Github: https://github.com/kolorobot/spring-boot-junit5.
Setup the project from the ground up
For the project setup you will need JDK 11 or later and Gradle or Maven (depending on your preference). The easiest way to get started with Spring Boot is to use the Initializr at https://start.spring.io. The only dependencies to select is Spring Web. Testing dependencies (Spring Boot Starter Test) are always included, no matter what dependencies you use in the generated project.
Build with Gradle
The default project file for Gradle build (gradle.build) generated with Initializr:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 | plugins { id 'org.springframework.boot' version '2.1.8.RELEASE' id 'io.spring.dependency-management' version '1.0.8.RELEASE' id 'java'}group = 'pl.codeleak.samples'version = '0.0.1-SNAPSHOT'sourceCompatibility = '11'repositories { mavenCentral()}dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test'} |
To add JUnit 5 support we need to exclude the old JUnit 4 dependency and include JUnit 5 (JUnit Jupiter) dependency:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'junit', module: 'junit' } testCompile 'org.junit.jupiter:junit-jupiter:5.5.2'}test { useJUnitPlatform() testLogging { events "passed", "skipped", "failed" }} |
Build with Maven
The default project file for Maven build (pom.xml) generated with Initializr:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | <?xml version="1.0" encoding="UTF-8"?><project> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.8.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>pl.codeleak.samples</groupId> <artifactId>spring-boot-junit5</artifactId> <version>0.0.1-SNAPSHOT</version> <name>spring-boot-junit5</name> <description>Demo project for Spring Boot and JUnit 5</description> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project> |
To add JUnit 5 support we need to exclude the old JUnit 4 dependency and include JUnit 5 (JUnit Jupiter) dependency:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | <properties> <junit.jupiter.version>5.5.2</junit.jupiter.version></properties><dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.jupiter.version}</version> <scope>test</scope> </dependency></dependencies> |
Use JUnit 5 in the test class
The test generated by the Initializr contains automatically generated JUnit 4 test. To apply JUnit 5 we need to change the imports and replace the JUnit 4 runner by the JUnit 5 extension. We can also make the class and the test method package protected:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.junit.jupiter.SpringExtension;@ExtendWith(SpringExtension.class)@SpringBootTestclass SpringBootJunit5ApplicationTests { @Test void contextLoads() { }} |
Tip: If you are new to JUnit 5 see my other posts about JUnit 5: https://blog.codeleak.pl/search/label/junit 5
Run the test
We can run the test either with Maven Wrapper: ./mvnw clean test or with Gradle Wrapper: ./gradlew clean test.
Source code
Please consult this commit for the changes related to the project setup.
Sample application with a single REST controller
The sample application is containing a single REST controller with three endpoints:
/tasks/{id}/tasks/tasks?title={title}
Each of the controller’s method is calling internally JSONPlaceholder – fake online REST API for testing and prototyping.
The structure of the project files is as follows:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | $ tree src/main/javasrc/main/java└── pl └── codeleak └── samples └── springbootjunit5 ├── SpringBootJunit5Application.java ├── config │ ├── JsonPlaceholderApiConfig.java │ └── JsonPlaceholderApiConfigProperties.java └── todo ├── JsonPlaceholderTaskRepository.java ├── Task.java ├── TaskController.java └── TaskRepository.java |
It also have the following static resources:
1 2 3 4 5 6 7 8 | $ tree src/main/resources/src/main/resources/├── application.properties├── static│ ├── error│ │ └── 404.html│ └── index.html└── templates |
The TaskController is delegating its work to the TaskRepository:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @RestControllerclass TaskController { private final TaskRepository taskRepository; TaskController(TaskRepository taskRepository) { this.taskRepository = taskRepository; } @GetMapping("/tasks/{id}") Task findOne(@PathVariable Integer id) { return taskRepository.findOne(id); } @GetMapping("/tasks") List<Task> findAll() { return taskRepository.findAll(); } @GetMapping(value = "/tasks", params = "title") List<Task> findByTitle(String title) { return taskRepository.findByTitle(title); }} |
The TaskRepository is implemented by JsonPlaceholderTaskRepository that is using internally RestTemplate for calling JSONPlaceholder (https://jsonplaceholder.typicode.com) endpoint:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | public class JsonPlaceholderTaskRepository implements TaskRepository { private final RestTemplate restTemplate; private final JsonPlaceholderApiConfigProperties properties; public JsonPlaceholderTaskRepository(RestTemplate restTemplate, JsonPlaceholderApiConfigProperties properties) { this.restTemplate = restTemplate; this.properties = properties; } @Override public Task findOne(Integer id) { return restTemplate .getForObject("/todos/{id}", Task.class, id); } // other methods skipped for readability} |
The application is configured via JsonPlaceholderApiConfig that is using JsonPlaceholderApiConfigProperties to bind some sensible properties from application.properties:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 | @Configuration@EnableConfigurationProperties(JsonPlaceholderApiConfigProperties.class)public class JsonPlaceholderApiConfig { private final JsonPlaceholderApiConfigProperties properties; public JsonPlaceholderApiConfig(JsonPlaceholderApiConfigProperties properties) { this.properties = properties; } @Bean RestTemplate restTemplate() { return new RestTemplateBuilder() .rootUri(properties.getRootUri()) .build(); } @Bean TaskRepository taskRepository(RestTemplate restTemplate, JsonPlaceholderApiConfigProperties properties) { return new JsonPlaceholderTaskRepository(restTemplate, properties); }} |
The application.properties contain several properties related to the JSONPlaceholder endpoint configuration:
1 2 3 4 | json-placeholder.root-uri=https://jsonplaceholder.typicode.comjson-placeholder.todo-find-all.sort=idjson-placeholder.todo-find-all.order=descjson-placeholder.todo-find-all.limit=20 |
Read more about @ConfigurationProperties in this blog post: https://blog.codeleak.pl/2014/09/using-configurationproperties-in-spring.html
Source code
Please consult this commit for the changes related to the source code of the application.
Creating Spring Boot tests
Spring Boot provides a number of utilities and annotations that support testing applications.
Different approaches can be used while creating the tests. Below you will find the most common cases for creating Spring Boot tests.
Spring Boot test with web server running on random port
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 | @ExtendWith(SpringExtension.class)@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class TaskControllerIntegrationTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Test void findsTaskById() { // act var task = restTemplate.getForObject("http://localhost:" + port + "/tasks/1", Task.class); // assert assertThat(task) .extracting(Task::getId, Task::getTitle, Task::isCompleted, Task::getUserId) .containsExactly(1, "delectus aut autem", false, 1); }} |
Spring Boot test with web server running on random port with mocked dependency
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | @ExtendWith(SpringExtension.class)@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class TaskControllerIntegrationTestWithMockBeanTest { @LocalServerPort private int port; @MockBean private TaskRepository taskRepository; @Autowired private TestRestTemplate restTemplate; @Test void findsTaskById() { // arrange var taskToReturn = new Task(); taskToReturn.setId(1); taskToReturn.setTitle("delectus aut autem"); taskToReturn.setCompleted(true); taskToReturn.setUserId(1); when(taskRepository.findOne(1)).thenReturn(taskToReturn); // act var task = restTemplate.getForObject("http://localhost:" + port + "/tasks/1", Task.class); // assert assertThat(task) .extracting(Task::getId, Task::getTitle, Task::isCompleted, Task::getUserId) .containsExactly(1, "delectus aut autem", true, 1); }} |
Spring Boot test with mocked MVC layer
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 | @ExtendWith(SpringExtension.class)@SpringBootTest@AutoConfigureMockMvcclass TaskControllerMockMvcTest { @Autowired private MockMvc mockMvc; @Test void findsTaskById() throws Exception { mockMvc.perform(get("/tasks/1")) .andDo(print()) .andExpect(status().isOk()) .andExpect(content().json("{\"id\":1,\"title\":\"delectus aut autem\",\"userId\":1,\"completed\":false}")); }} |
Spring Boot test with mocked MVC layer and mocked dependency
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | @ExtendWith(SpringExtension.class)@SpringBootTest@AutoConfigureMockMvcclass TaskControllerMockMvcWithMockBeanTest { @Autowired private MockMvc mockMvc; @MockBean private TaskRepository taskRepository; @Test void findsTaskById() throws Exception { // arrange var taskToReturn = new Task(); taskToReturn.setId(1); taskToReturn.setTitle("delectus aut autem"); taskToReturn.setCompleted(true); taskToReturn.setUserId(1); when(taskRepository.findOne(1)).thenReturn(taskToReturn); // act and assert mockMvc.perform(get("/tasks/1")) .andDo(print()) .andExpect(status().isOk()) .andExpect(content().json("{\"id\":1,\"title\":\"delectus aut autem\",\"userId\":1,\"completed\":true}")); }} |
Spring Boot test with mocked web layer
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 | @ExtendWith(SpringExtension.class)@WebMvcTest@Import(JsonPlaceholderApiConfig.class)class TaskControllerWebMvcTest { @Autowired private MockMvc mockMvc; @Test void findsTaskById() throws Exception { mockMvc.perform(get("/tasks/1")) .andDo(print()) .andExpect(status().isOk()) .andExpect(content().json("{\"id\":1,\"title\":\"delectus aut autem\",\"userId\":1,\"completed\":false}")); }} |
Spring Boot test with mocked web layer and mocked dependency
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @ExtendWith(SpringExtension.class)@WebMvcTestclass TaskControllerWebMvcWithMockBeanTest { @Autowired private MockMvc mockMvc; @MockBean private TaskRepository taskRepository; @Test void findsTaskById() throws Exception { // arrange var taskToReturn = new Task(); taskToReturn.setId(1); taskToReturn.setTitle("delectus aut autem"); taskToReturn.setCompleted(true); taskToReturn.setUserId(1); when(taskRepository.findOne(1)).thenReturn(taskToReturn); // act and assert mockMvc.perform(get("/tasks/1")) .andDo(print()) .andExpect(status().isOk()) .andExpect(content().json("{\"id\":1,\"title\":\"delectus aut autem\",\"userId\":1,\"completed\":true}")); }} |
Run all tests
We can run all tests either with Maven Wrapper: ./mvnw clean test or with Gradle Wrapper: ./gradlew clean test.
The results of running the tests with Gradle:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 | $ ./gradlew clean test> Task :testpl.codeleak.samples.springbootjunit5.SpringBootJunit5ApplicationTests > contextLoads() PASSEDpl.codeleak.samples.springbootjunit5.todo.TaskControllerWebMvcTest > findsTaskById() PASSEDpl.codeleak.samples.springbootjunit5.todo.TaskControllerIntegrationTestWithMockBeanTest > findsTaskById() PASSEDpl.codeleak.samples.springbootjunit5.todo.TaskControllerWebMvcWithMockBeanTest > findsTaskById() PASSEDpl.codeleak.samples.springbootjunit5.todo.TaskControllerIntegrationTest > findsTaskById() PASSEDpl.codeleak.samples.springbootjunit5.todo.TaskControllerMockMvcTest > findsTaskById() PASSEDpl.codeleak.samples.springbootjunit5.todo.TaskControllerMockMvcWithMockBeanTest > findsTaskById() PASSEDBUILD SUCCESSFUL in 7s5 actionable tasks: 5 executed |
References
- https://docs.spring.io/spring-boot/docs/2.1.8.RELEASE/reference/html/boot-features-testing.html
- https://spring.io/guides/gs/testing-web/
- https://github.com/spring-projects/spring-boot/issues/14736
Published on Java Code Geeks with permission by Rafal Borowiec, partner at our JCG program. See the original article here: Spring Boot testing with JUnit 5 Opinions expressed by Java Code Geeks contributors are their own. |




