This lab explores automated testing at the unit and integration levels, and what Spring Boot brings to the table.
1. The testing "Starter"
➜ Review the demo project’s build dependencies:
dependencies {
compile 'org.springframework.boot:spring-boot-starter-thymeleaf'
compile 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
testCompile 'org.springframework.boot:spring-boot-starter-test'
}
The file build.gradle
already contains a test dependency by the name of spring-boot-starter-test
.
➜ Review the dependencies that this starter brings into the project:
$ gradle dependencies --configuration testCompile
Here’s the relevant output:
... \--- org.springframework.boot:spring-boot-starter-test -> 2.0.3.RELEASE +--- org.springframework.boot:spring-boot-starter:2.0.3.RELEASE (*) +--- org.springframework.boot:spring-boot-test:2.0.3.RELEASE | \--- org.springframework.boot:spring-boot:2.0.3.RELEASE (*) +--- org.springframework.boot:spring-boot-test-autoconfigure:2.0.3.RELEASE | +--- org.springframework.boot:spring-boot-test:2.0.3.RELEASE (*) | \--- org.springframework.boot:spring-boot-autoconfigure:2.0.3.RELEASE (*) +--- com.jayway.jsonpath:json-path:2.4.0 | +--- net.minidev:json-smart:2.3 | | \--- net.minidev:accessors-smart:1.2 | | \--- org.ow2.asm:asm:5.0.4 | \--- org.slf4j:slf4j-api:1.7.25 +--- junit:junit:4.12 | \--- org.hamcrest:hamcrest-core:1.3 +--- org.assertj:assertj-core:3.9.1 +--- org.mockito:mockito-core:2.15.0 | +--- net.bytebuddy:byte-buddy:1.7.9 -> 1.7.11 | +--- net.bytebuddy:byte-buddy-agent:1.7.9 -> 1.7.11 | \--- org.objenesis:objenesis:2.6 +--- org.hamcrest:hamcrest-core:1.3 +--- org.hamcrest:hamcrest-library:1.3 | \--- org.hamcrest:hamcrest-core:1.3 +--- org.skyscreamer:jsonassert:1.5.0 | \--- com.vaadin.external.google:android-json:0.0.20131108.vaadin1 +--- org.springframework:spring-core:5.0.7.RELEASE (*) +--- org.springframework:spring-test:5.0.7.RELEASE | \--- org.springframework:spring-core:5.0.7.RELEASE (*) \--- org.xmlunit:xmlunit-core:2.5.1
By using this one starter dependency, we get a number of useful testing libraries that we would otherwise have to include individually:
-
junit:junit
: the canonical testing library for Java -
org.springframework:spring-test
: spring’s test support library -
org.mockito:mockito-core
: the canonical java mocking library -
org.hamcrest:hamcrest-core
: the well-known assertions library -
org.assertj:assertj-core
: an alternative and popular assertions library -
org.skyscreamer:jsonassert
: an assertions library for working with JSON objects -
com.jayway.jsonpath:json-path
: an expression language for JSON documents analogous to what XPath does for xml
2. Refactor TodoController
In the last lab, we introduced the domain object Todo.java
and an accompanying controller, the TodoController
, for the purpose of demonstrating the use of Spring Controllers with Template Engines.
That controller just inlined a hard-coded list of Todo items, and it served its purpose. Let’s properly refactor this code and separate the data concern from the controller.
The Repository pattern describes an object responsible for data operations for a given entity. The Spring Data project defines the CrudRepository interface, which we will discuss in a later lab. For now, let’s define our own TodoRepository
, with a similar contract:
package com.example.demo;
import java.util.List;
import java.util.Optional;
public interface TodoRepository {
Todo save(Todo todo);
void delete(Todo todo);
List<Todo> findAll();
long count();
Optional<Todo> findById(Long id);
}
In the next lab, we’ll explore the Spring Data project, and integrate a Spring Boot application to a backing database. For now, let’s implement a simple in-memory version backed by a Java HashMap
, the InMemoryTodoRepository
.
➜ Create a class named InMemoryTodoRepository.java
that implements the TodoRepository
interface, with default implementations of all of the interface’s methods, and add a private Map field, perhaps named todoMap
, to hold the Todo items in memory:
package com.example.demo;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class InMemoryTodoRepository implements TodoRepository {
private final Map<Long, Todo> todoMap = new HashMap<>();
@Override
public List<Todo> findAll() {
return null;
}
@Override
public Todo save(Todo todo) {
return null;
}
@Override
public void delete(Todo todo) {
}
@Override
public Optional<Todo> findById(Long id) {
return Optional.empty();
}
@Override
public long count() {
return 0;
}
}
The basic strategy is that each Todo item will have a unique id, of type Long
, that will be used as the key in the HashMap
.
➜ In Todo.java
, add an id
field of type Long
:
import lombok.Data;
import java.time.LocalDate;
@Builder
@Data
public class Todo {
+ private final Long id;
private final String title, description;
private final LocalDate dueDate;
}
The implementation of InMemoryTodoRepository
will be supported by tests. Unit tests are sufficient here; the Spring testing support isn’t needed yet. We’ll use JUnit and AssertJ.
If you’re not familiar with AssertJ and its assertions, take a little time right now to visit the web site, scan through the documentation, and familiarize yourself with it. |
➜ In src/test/java/com/example/demo
, create a test for this class, named InMemoryTodoRepositoryTest
(in IntelliJ, Cmd+Shift+T will trigger this action), :
package com.example.demo;
import org.junit.Before;
public class InMemoryTodoRepositoryTest {
private InMemoryTodoRepository repository;
private Todo todo;
@Before
public void setup() {
repository = new InMemoryTodoRepository();
todo = Todo.builder()
.title("My Todo")
.description("it's urgent")
.dueDate(LocalDate.now())
.build();
}
}
Above, we see the use of the Builder pattern (as implemented by the project Lombok library) that makes clear how the object is configured.
Lombok builders have a toBuilder
option, for the purpose of creating an object instance patterned after another. Enable this feature:
@@ -5,9 +5,10 @@ import lombok.Data;
import java.time.LocalDate;
-@Builder
+@Builder(toBuilder = true)
@Data
public class Todo {
private final Long id;
private final String title, description;
private final LocalDate dueDate;
}
2.1. Develop the Repository
Follow the red green refactor process of TDD to gradually develop a working implementation for InMemoryTodoRepository
.
Here’s a first, simple test:
@Test
public void shouldBeInitiallyEmpty() {
assertThat(repository.findAll()).isEmpty();
}
Run it. Watch it fail. Make it pass.
Proceed to add another test until the complete TodoRepository
interface is working to your satisfaction.
Here is my final test class implementation:
package com.example.demo;
import org.junit.Before;
import org.junit.Test;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.assertThat;
public class InMemoryTodoRepositoryTest {
private InMemoryTodoRepository repository;
private Todo todo;
@Before
public void setup() {
repository = new InMemoryTodoRepository();
todo = Todo.builder()
.title("My Todo")
.description("it's urgent")
.dueDate(LocalDate.now())
.build();
}
@Test
public void shouldBeInitiallyEmpty() {
assertThat(repository.findAll()).isEmpty();
assertThat(repository.count()).isEqualTo(0);
}
@Test
public void shouldAddAnEntry() {
Todo savedTodo = repository.save(todo);
assertThat(savedTodo.getId()).isNotNull();
assertThat(savedTodo.getTitle()).isEqualTo(todo.getTitle());
assertThat(repository.count()).isEqualTo(1);
assertThat(repository.findById(savedTodo.getId()).isPresent()).isTrue();
}
@Test
public void shouldNotFindTodoWithAbsentId() {
assertThat(repository.findById(123L).isPresent()).isFalse();
}
@Test
public void shouldRemoveAnEntry() {
Todo savedTodo = repository.save(todo);
repository.delete(savedTodo);
assertThat(repository.findAll()).isEmpty();
}
@Test
public void shouldHandleDeleteATodoWithNoId() {
repository.delete(todo);
assertThat(repository.findAll()).isEmpty();
}
@Test
public void shouldHandleDeleteATodoWithIdNotPresent() {
Todo todoWithId = todo.toBuilder().id(17L).build();
repository.delete(todoWithId);
assertThat(repository.findAll()).isEmpty();
}
@Test
public void shouldReplaceTodoWithSameId() {
Todo savedTodo = repository.save(todo);
Long id = savedTodo.getId();
Todo anotherTodo = Todo.builder()
.id(id)
.title("Another Todo")
.description("it's not as urgent")
.dueDate(LocalDate.now().plus(1, ChronoUnit.DAYS))
.build();
repository.save(anotherTodo);
assertThat(repository.count()).isEqualTo(1);
assertThat(repository.findById(id).isPresent()).isTrue();
assertThat(repository.findById(id).get().getTitle()).isEqualTo(anotherTodo.getTitle());
assertThat(repository.findById(id).get().getDescription()).isEqualTo(anotherTodo.getDescription());
}
@Test
public void shouldHoldMultipleEntries() {
int n = 100;
for (int i = 0; i < n; i++) {
repository.save(Todo.builder()
.title("title" + i)
.description("description" + i)
.dueDate(LocalDate.now()).build()
);
}
assertThat(repository.count()).isEqualTo(n);
}
}
And here’s the repository implementation:
package com.example.demo;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
public final class InMemoryTodoRepository implements TodoRepository {
private final Map<Long, Todo> todoMap = new HashMap<>();
private final AtomicLong sequence = new AtomicLong(0);
@Override
public Todo save(Todo todo) {
if (todo.getId() == null) {
Long id = sequence.incrementAndGet();
Todo todoWithId = todo.toBuilder().id(id).build();
todoMap.put(id, todoWithId);
return todoWithId;
} else {
todoMap.replace(todo.getId(), todo);
return todo;
}
}
@Override
public void delete(Todo todo) {
todoMap.remove(todo.getId());
}
@Override
public Optional<Todo> findById(Long id) {
return Optional.ofNullable(todoMap.get(id));
}
@Override
public long count() {
return todoMap.size();
}
@Override
public List<Todo> findAll() {
return new ArrayList<>(todoMap.values());
}
}
2.2. Expose the Repository Bean
Recall that the @SpringBootApplication
annotation implies that our application class is also a Spring @Configuration
class. This means we can define @Bean
-annotated methods directly in this class.
➜ Expose a TodoRepository
Spring bean for the application:
@@ -3,6 +3,7 @@ package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
@SpringBootApplication
@EnableConfigurationProperties(HelloProperties.class)
@@ -11,4 +12,9 @@ public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
+
+ @Bean
+ public TodoRepository todoRepository() {
+ return new InMemoryTodoRepository();
+ }
}
2.3. Move Data Initialization to the Runner
Instead of constructing the Todo items directly in the TodoController
, move that code to Runner.java
, the CommandLineProcessor
. This is the right place for initializing our application with a set of Todo items.
@@ -5,20 +5,46 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+
@Component
public class Runner implements CommandLineRunner {
private final Logger logger = LoggerFactory.getLogger(Runner.class);
private final HelloService helloService;
+ private final TodoRepository todoRepository;
- public Runner(HelloService helloService) {
+ public Runner(HelloService helloService, TodoRepository todoRepository) {
this.helloService = helloService;
+ this.todoRepository = todoRepository;
}
@Override
- public void run(String... args) throws Exception {
+ public void run(String... args) {
helloService.greet();
+
+ LocalDate nextWeek = LocalDate.now().plus(7, ChronoUnit.DAYS);
+
+ todoRepository.save(Todo.builder()
+ .title("Shop")
+ .description("Go shopping ahead of trip")
+ .dueDate(nextWeek).build());
+ todoRepository.save(Todo.builder()
+ .title("Pack")
+ .description("Be sure to pack your things")
+ .dueDate(nextWeek).build());
+ todoRepository.save(Todo.builder()
+ .title("Drive")
+ .description("Drive to the airport")
+ .dueDate(nextWeek).build());
+ todoRepository.save(Todo.builder()
+ .title("Fly")
+ .description("Fly to some mysterious destination")
+ .dueDate(nextWeek).build());
+
+
logger.debug("exiting run method..");
}
}
Above, we:
-
Autowire the repository via the constructor.
-
Save the Todo items to the repository inside the
run()
method.
2.4. Retrofit TodoController endpoint
The only task remaining is to retrofit the TodoController
to use the repository to fetch the Todo’s:
package com.example.demo;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Controller
public class TodoController {
private final TodoRepository todoRepository;
public TodoController(TodoRepository todoRepository) {
this.todoRepository = todoRepository;
}
@GetMapping("/todos")
public String fetchTodos(Model model) {
List<TodoViewModel> todoViews = todoRepository
.findAll()
.stream()
.map(TodoViewModel::new)
.collect(Collectors.toList());
model.addAttribute("todos", todoViews);
return "todos"; // the name of the template to render
}
}
-
Fire up the application and verify that the
/todos
endpoint still functions. -
Using gradle, run all tests from the command line:
$ gradle test
-
Inspect the generated HTML test report at
build/reports/tests/test/index.html
.
This completes the refactoring. Unit testing the Todo repository was fairly straightforward. Even for more complex classes that have delegates, mocks can make it easy to test an object under test in isolation, and under different sets of conditions.
Let’s turn our attention to Spring Boot’s test support by developing a REST API for Todo’s.
3. Developing a REST API for Todo’s
We have an endpoint that returns an HTML page listing Todos. We’d also like to have a REST API for creating, fetching, updating, and deleting Todos.
Let’s develop this from the outside in: by writing integration tests that expect these API endpoints to exist, and to behave in a certain way. We’ll then make the tests pass by implementing the endpoints in question.
➜ In src/test/java/com/example/demo
, create a test named TodoRestControllerTests.java
, as follows:
package com.example.demo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import static org.junit.Assert.fail;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@RunWith(SpringRunner.class) (1)
@SpringBootTest(webEnvironment = RANDOM_PORT) (2)
public class TodoRestControllerTests {
@Test
public void shouldFetchTodos() {
// to be implemented shortly
}
}
1 | @RunWith tells JUnit to use the Spring runner for this test |
2 | @SpringBootTest will enlist the help of Spring Boot to start the application context, and to identify the main class for our application automatically. webEnvironment asks Spring to start the application on any available random port. |
➜ Even though no test is yet written, go ahead and run this test class
You should see the Spring application start up; the empty test will pass.
The idea is to have Spring start our application, and then to run tests that make HTTP calls to the application from the outside. Spring’s RestTemplate
is an ideal and simple REST client for this. Spring Boot provides TestRestTemplate
, a version that already knows the base URL of the running application.
➜ Autowire a TestRestTemplate
into the test, like this:
@Autowired
private TestRestTemplate template;
Here’s a stab at a first test:
@Test
public void shouldFetchTodos() {
ResponseEntity<List<Todo>> response = template.exchange("/api/todos", HttpMethod.GET,
null, new ParameterizedTypeReference<List<Todo>>() {}); (1)
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
1 | The ParamaterizedTypeReference is a mechanism for dealing with Java generics to unmarshal response JSON back to a list of Todo objects. |
We expect an endpoint at /api/todos
to return at least an HTTP 200 response.
-
Run this test
-
Watch it fail
-
Implement the code necessary to make it pass:
-
Create a new class:
TodoRestController.java
. -
Annotate it with
@RestController
-
Annotate the class with a
@RequestMapping
annotation with a specified route matching/api/todos
-
Autowire the class with a
TodoRepository
-
Implement a simple
@GetMapping
-annotated handler method to return the Todo items
-
package com.example.demo;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/todos")
public class TodoRestController {
private final TodoRepository repository;
public TodoRestController(TodoRepository repository) {
this.repository = repository;
}
@GetMapping
public List<Todo> list() {
return repository.findAll();
}
}
Re-run the test; it should pass. Congratulations, you’ve implemented the first endpoint!
The goal is to proceed in a test-driven fashion to implement additional tests until we have a fully implemented Rest API:
-
GET
api/todos
to fetch the list -
GET
api/todos/{id}
to fetch a single item by id -
POST
api/todos
to create a new Todo -
PUT
api/todos/{id}
for updates -
DELETE
api/todos/{id}
to remove an entry
Given the full set of tests below, complete the implementation of TodoRestController
and make them all pass.
package com.example.demo;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class TodoRestControllerTests {
@Autowired
private TestRestTemplate template;
@MockBean (1)
private TodoRepository repository;
private Todo todo1;
@Before
public void setup() {
List<Todo> todos = new ArrayList<>();
todo1 = Todo.builder().title("Todo1").id(1L).build();
todos.add(todo1);
Todo todo2 = Todo.builder().title("Todo2").id(2L).build();
todos.add(todo2);
when(repository.findAll()).thenReturn(todos);
when(repository.findById(anyLong())).thenReturn(Optional.empty());
when(repository.findById(1L)).thenReturn(Optional.of(todo1));
when(repository.findById(2L)).thenReturn(Optional.of(todo2));
when(repository.save(any(Todo.class))).thenReturn(todo1);
}
@Test
public void shouldReturnTodos() {
ResponseEntity<List<Todo>> response = template.exchange("/api/todos", HttpMethod.GET,
null, new ParameterizedTypeReference<List<Todo>>() {});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<Todo> todos = response.getBody();
assertThat(todos.size()).isEqualTo(2);
}
@Test
public void shouldReturnASingleTodo() {
ResponseEntity<Todo> response = template.getForEntity("/api/todos/1", Todo.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Todo todo = response.getBody();
assertThat(todo.getTitle()).isEqualTo("Todo1");
}
@Test
public void shouldNotFindTodoId3() {
ResponseEntity<Todo> response = template.getForEntity("/api/todos/3", Todo.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
public void shouldSaveATodo() {
ResponseEntity<Todo> response = template.postForEntity("/api/todos", todo1, Todo.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Todo todo = response.getBody();
assertThat(todo.getTitle()).isEqualTo("Todo1");
}
@Test
public void shouldDeleteATodo() {
ResponseEntity<Todo> response =
template.exchange("/api/todos/1", HttpMethod.DELETE, null, Todo.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
1 | The @MockBean annotation is another Spring Boot convenience that not only mocks an object, but wires the mock into all Spring beans that require it. In this case, I’ve decided that the test should test only the controller, not the repository: the repository is mocked and instrumented to return Todo instances fabricated by the test. i.e. this test is in full control of the data setup. |
Run all tests, watch them fail, and, one by one, make them pass. For reference, here is a completed implementation of the controller:
package com.example.demo;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/todos")
public class TodoRestController {
private final TodoRepository repository;
public TodoRestController(TodoRepository repository) {
this.repository = repository;
}
@GetMapping
public List<Todo> list() {
return repository.findAll();
}
@GetMapping("{id}")
public ResponseEntity<Todo> get(@PathVariable Long id) {
Optional<Todo> optional = repository.findById(id);
return optional.isPresent() ?
ResponseEntity.ok(optional.get()) : ResponseEntity.notFound().build();
}
@PostMapping
public ResponseEntity<Todo> create(@RequestBody Todo todo) {
Todo savedTodo = repository.save(todo);
return new ResponseEntity<>(savedTodo, HttpStatus.CREATED);
}
@DeleteMapping("{id}")
public ResponseEntity<Todo> delete(@PathVariable Long id) {
Optional<Todo> optional = repository.findById(id);
if (optional.isPresent()) {
repository.delete(optional.get());
}
return ResponseEntity.noContent().build();
}
}
Once you have all tests passing, fire up the application and exercise the GET endpoints directly from a browser. For HTTP POST and other verbs, you’ll need a tool such as Postman or a command-line HTTP client such as curl
or HTTPie.
There’s more to Spring’s support for integration tests.
-
MockMvc
supports controller integration testing in a lighter-weight fashion, without actually starting a web server. -
JsonPath
provides a DSL for validating JSON responses. -
Spring Boot offers support for partial construction of an application context for testing slices of your application.
To find out more about automated test support in Spring Boot, check out:
-
Phil Webb’s blog entry on Testing improvements in Spring Boot 1.4
-
The spring.io guide entitled Testing the Web Layer