原文:https://rieckpil.de/testing-spring-boot-applications-with-rest-assured/
REST Assured is a Java DSL (Domain Specific Langauge) that aims to simplify testing REST APIs. It follows a BDD (Behavior Driven Development) approach and is influenced by testing APIs with dynamic languages like Groovy. We can use REST Assured to test the REST API of any Java project as it is framework independent. However, REST Assured comes with a great Spring integration for testing our @RestController
endpoints that we're about to explore with this article.
Spring Boot and REST Assured Project Setup
For our demo application we use Java 11, Spring Boot 2.4.0, and the following dependencies:
1 2 3 4 5 6 7 8 9 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 42 43 44 45 46 47 48 49 50 | <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.4.0</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>de.rieckpil.blog</groupId> <artifactId>spring-boot-rest-assured</artifactId> <version>0.0.1-SNAPSHOT</version> <name>spring-boot-rest-assured</name> <properties> <java.version>11</java.version> <groovy.version>3.0.7</groovy.version> <rest-assured.version>4.3.3</rest-assured.version> </properties> <dependencies> <!-- Spring Boot Starters for Web, Validation, and Security --> <dependency> <groupId>io.rest-assured</groupId> <artifactId>spring-mock-mvc</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <!-- Spring Boot Maven Plugin --> </build> </project> |
As Spring Boot manages the dependency version of REST Assured and all its modules, we can define the spring-mock-mvc
without an explicit version. We can override the managed version consistently with the rest-assured.version
property.
If we mix up our dependency versions for REST Assured and try to outsmart Spring Boot, we might see several exceptions during test runtime:
1 2 3 4 5 | java.lang.NoClassDefFoundError: io/restassured/internal/common/assertion/AssertParameter at io.restassured.module.mockmvc.config.MockMvcParamConfig.<init>(MockMvcParamConfig.java:65) at io.restassured.module.mockmvc.config.MockMvcParamConfig.<init>(MockMvcParamConfig.java:43) at io.restassured.module.mockmvc.config.RestAssuredMockMvcConfig.<init>(RestAssuredMockMvcConfig.java:56) |
As REST Assured has quite some transitive dependencies (Groovy related dependencies, Apache HttpClient, Hamcrest, and Tagsoup), we should favor the manged version from our Spring Boot release.
As I've seen several integration issues for REST Assured and Java 11 projects, consider the following tips:
- IntelliJ IDEA can help to identify duplicated dependency includes for a Maven project. We can open a visual dependency graph with the following steps: right-click inside our
pom.xml
-> Maven -> Show Dependencies. A red arrow indicates a violation for dependency convergence. The Maven Enforcer plugin can even detect this as part of your build. - REST Assured also provides a
rest-assured-all
dependency, that can help to solve split package problems. - When using Spring Boot together with REST Assured, you might also stumble over exceptions for Groovy related parts of this testing library. Explicitly overriding Spring Boot's default Groovy version with
groovy.version
inside thepom.xml
seems to help here.
The Spring MVC RestController Under Test
Our sample application is a book store that exposes the following REST API endpoints to manage its inventory:
1 2 3 4 5 6 7 8 9 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 | @RestController @RequestMapping("/api/books") public class BookController { private final BookService bookService; public BookController(BookService bookService) { this.bookService = bookService; } @GetMapping public List<Book> getAllBooks(@RequestParam(value = "amount", defaultValue = "500") int amount) { return bookService.getAllBooks(amount); } @GetMapping("/{id}") public ResponseEntity<Book> getBookById(@PathVariable("id") Long id) { return ResponseEntity.ok(bookService.getBookById(id)); } @PostMapping public ResponseEntity<Void> createNewBook( @Valid @RequestBody BookRequest bookRequest, UriComponentsBuilder uriComponentsBuilder) { Long bookId = bookService.createNewBook(bookRequest); UriComponents uriComponents = uriComponentsBuilder.path("/api/books/{id}").buildAndExpand(bookId); HttpHeaders headers = new HttpHeaders(); headers.setLocation(uriComponents.toUri()); return new ResponseEntity<>(headers, HttpStatus.CREATED); } } |
The actual implementation of the BookService
doesn't matter for this demonstration and we can assume it stores our book entities somewhere (e.g. database or in-memory).
For a more realistic example, we'll secure the HTTP POST endpoint and only allow authenticated users that have the ADMIN
role to access it:
JUnit 5 & Mockito Cheat Sheet
Answering 24 questions for the two most essential Java testing libraries.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .csrf() .disable() .httpBasic() .and() .authorizeRequests( requests -> requests .mvcMatchers(HttpMethod.GET, "/api/books").permitAll() .mvcMatchers(HttpMethod.GET, "/api/books/*").permitAll() .mvcMatchers(HttpMethod.POST, "/api/books").hasRole("ADMIN") .anyRequest().authenticated()); } } |
REST Assured Powered MockMvc Test
There are multiple ways to configure REST Assured for a Spring MVC test case. We can manually pass a list of controller objects, provide a MockMvcBuilder
, pass a WebApplicationContext
or a MockMvc
instance.
As we can inject an already initialized MockMvc
bean to our test when using Spring Boot's test slice annotation @WebMvcTest
, we'll use it for our REST Assured configuration.
1 2 3 4 | @BeforeEach void setUp() { RestAssuredMockMvc.mockMvc(mockMvc); } |
All upcoming requests for this test class will target this MockMvc
instance unless we override it explicitly.
With this setup in place, we can write our first test that ensures unauthenticated clients can request our book store's inventory:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @Test void shouldAllowBookRetrievalWithoutAuthentication() { Mockito.when(bookService.getAllBooks(42)).thenReturn( List.of(new Book(42L, "42", "REST Assured With Spring Boot", "Duke"))); RestAssuredMockMvc .given() .auth().none() .param("amount", 42) .when() .get("/api/books") .then() .statusCode(200) .body("$.size()", Matchers.equalTo(1)) .body("[0].id", Matchers.equalTo(42)) .body("[0].isbn", Matchers.equalTo("42")) .body("[0].author", Matchers.equalTo("Duke")) .body("[0].title", Matchers.equalTo("REST Assured With Spring Boot")); } |
Due to REST Assured's fluent API, one should easily grasp the test setup above. REST Assured also follows a BDD style and .given().when().then()
gives each request a standardized schema.
For writing expectations for our HTTP response body/status/header, we then use Matchers
from Hamcrest.
The test above doesn't use static imports because there is a small pitfall. When importing the .given()
method from REST Assured, we have to make sure it's not the traditional non-Spring variant:
1 2 3 4 5 6 | // import this when using static imports import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; // NOT this import static io.restassured.RestAssured.given; |
Furthermore, there is a potential second pitfall when using a static import and Mockito in the same test.
For tests where we don't need a .given()
step (e.g., we don't pass any query parameter/path variable/header), we can omit it and start our request specification with .when()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @Test void shouldAllowBookRetrievalWithoutAuthenticationShort() { Mockito.when(bookService.getAllBooks(42)).thenReturn( List.of(new Book(42L, "42", "REST Assured With Spring Boot", "Duke"))); RestAssuredMockMvc .when() .get("/api/books") .then() .statusCode(200) .body("$.size()", Matchers.equalTo(1)) .body("[0].id", Matchers.equalTo(42)) .body("[0].isbn", Matchers.equalTo("42")) .body("[0].author", Matchers.equalTo("Duke")) .body("[0].title", Matchers.equalTo("REST Assured With Spring Boot")); } |
As soon as we add a static import for REST Assured's .when()
we would * with Mockito's .when()
unless we are more explicit and use Mockito.when()
. This is something to keep in mind.
Testing a Protected Endpoint
Most tutorials end here as they showed how to test only one Hello World API endpoint. But that's not what we aim for. Let's take it to the next step and write a test for our protected HTTP POST endpoint.
REST Assured already comes with great support for several authentication mechanisms. Besides that, we can integrate the SecurityMockMvcRequestPostProcessors
from the spring-security-test
dependency that we might be already familiar with from other MockMvc
tests.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Test void shouldAllowBookCreationForAuthenticatedAdminUsers() { Mockito.when(bookService.createNewBook(any(BookRequest.class))).thenReturn(42L); RestAssuredMockMvc .given() .auth().with(SecurityMockMvcRequestPostProcessors.user("duke").roles("ADMIN")) .contentType("application/json") .body("{\"title\": \"Effective Java\", \"isbn\":\"978-0-13-468599-1 \", \"author\":\"Joshua Bloch\"}") .when() .post("/api/books") .then() .statusCode(201) .header("Location", Matchers.containsString("/api/books/42")); } |
As part of the .given()
step, we define the HTTP request body, set the content type, and associate a user. The Spring Security Test RequestPostProcessor
functionality gives us full control over the user setup (username, password, roles, authorities) for this test.
We can also use the @WithMockUser
annotation for our tests:
1 2 3 4 5 6 7 8 9 10 11 12 13 | @Test @WithMockUser(username = "duke", roles = {"USER", "EDITOR"}) void shouldBlockBookCreationForNonAdminUsers() { RestAssuredMockMvc .given() .contentType("application/json") .body("{\"title\": \"Effective Java\", \"isbn\":\"978-0-13-468599-1 \", \"author\":\"Joshua Bloch\"}") .when() .post("/api/books") .then() .statusCode(403); } |
Further Tips For REST Assured with Spring Boot
For Spring WebFlux controller endpoints that use the WebTestClient
for tests, REST Assured provides a spring-web-test-client
module. There are also extensions and support for both Scala and Kotlin available.
While REST Assured is designed to verify and test REST APIs, we can also test @Controller
endpoints with the spring-mock-mvc
module.
Let's assume one of our controller endpoints returns a Thymeleaf view and populates its Model
. As we can add any ResultMatcher
to our .then()
verification part, we can write the following expectation:
1 2 3 4 | .then() .statusCode(200) .expect(MockMvcResultMatchers.model().attributeExists("analyticsData")) .expect(MockMvcResultMatchers.view().name("dashboard")); |
However, REST Assured won't provide any additional syntactic sugar or helper methods to verify @Controller
endpoints and we just use Spring's MockMvcResultMatchers
.
We can also use REST Assured when writing integration tests for our Spring Boot application. Let's verify that we can create a book and a subsequent query for the book returns it:
1 2 3 4 5 6 7 8 9 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 | @SpringBootTest( webEnvironment = WebEnvironment.RANDOM_PORT, properties = { "spring.security.user.name=duke", "spring.security.user.password=secret", "spring.security.user.roles=ADMIN" }) class ApplicationTest { @LocalServerPort private Integer port; @Test void shouldCreateBook() { ExtractableResponse<Response> response = RestAssured .given() .filter(new RequestLoggingFilter()) .auth().preemptive().basic("duke", "secret") .contentType("application/json") .body("{\"title\": \"Effective Java\", \"isbn\":\"978-0-13-468599-1\", \"author\":\"Joshua Bloch\"}") .when() .post("http://localhost:" + port + "/api/books") .then() .statusCode(201) .extract(); RestAssured .when() .get(response.header("Location")) .then() .statusCode(200) .body("id", Matchers.notNullValue()) .body("isbn", Matchers.equalTo("978-0-13-468599-1")) .body("author", Matchers.equalTo("Joshua Bloch")) .body("title", Matchers.equalTo("Effective Java")); } } |
Keep in mind that we don't use MockMvc
and access our Spring Boot application on a local port for such an integration test. That's why we have to replace RestAssuredMockMvc.given()
with RestAssured.given()
.
Summary of using REST Assured with Spring Boot
Due to the feature-rich and convenient API of MockMvc
and Spring Boot's excellent test support, I wouldn't consider REST Assured a must-have. The spring-mock-mvc
module is basically a wrapper on top of MockMvc
that brings REST Assured's fluent API to it. However, REST Assured still offers great features on top:
- JSON schema validation
- Reusable Specifications to avoid duplicating response expectations and/or request parameter for multiple tests
- Complex parsing and validation by taking advantage of Groovy's collection API
If your team is already familiar with REST Assured from testing REST APIs of other projects (e.g. non-Spring Boot), it's surely a good fit for your Spring Boot application. Once resolve potential incompatibility issues and are aware of the pitfalls, REST Assured can be a great add-on for your testing toolbox.
All source code examples for this REST Assured with Spring Boot demo are available on GitHub.
Have fun using REST Assured with Spring Boot,
Philip