Alongside the WebClient, Spring provides a WebTestClient for testing purposes. The API of this class is similar to the WebClient
and allows the assertion of all parts of the HTTP response. With this blog post, I'll demonstrate how to use the WebTestClient
to write integration tests for a Spring Boot REST API.
TL;DR:
- Spring Boot autoconfigures a
WebTestClient
once you use@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
- Easy-to-use assertions for the response body, status code, and headers of your REST API
- If you already know the
WebClient
, using theWebTestClient
will be straightforward
Spring Boot Application Setup
To provide a reasonable example to showcase the capabilities of the WebTestClient
, we're developing and testing a Java 11 and Spring Boot 2.5 application.
We include the Spring Boot Starter Web (to auto-configure Tomcat), WebFlux (includes the WebClient
and WebTestClient
), and Validation (was recently removed from Web) alongside the Spring Boot Starter Test (aka. testing swiss-army knife):
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | <?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.5.0</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>de.rieckpil.blog</groupId> <artifactId>spring-web-test-client</artifactId> <version>0.0.1-SNAPSHOT</version> <name>spring-web-test-client</name> <description>Test Endpoints using WebTestClient</description> <properties> <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-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> <scope>test</scope> </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> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-failsafe-plugin</artifactId> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project> |
When using both the Spring Boot Starter Web and WebFlux, Spring Boot assumes we want a blocking servlet stack and auto-configures the embedded Tomcat for us. We can still use WebFlux features like the WebClient
(preferred over the RestTemplate
) for fetching data from remote APIs or use the WebTestClient for testing purposes.
Spring Boot Application Walkthrough
Next, let's have a look at the REST API of the sample application. This API is all about serving data about the User
resource in a RESTful manner.
It allows creating, querying, and deleting users:
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 | @RestController @RequestMapping(value = "/api/users", produces = APPLICATION_JSON_VALUE) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping public List<User> getAllUsers() { return userService.getAllUsers(); } @GetMapping("/{id}") public User getUserById(@PathVariable("id") Long id) { return userService .getUserById(id) .orElseThrow(() -> new UserNotFoundException(String.format("User with id [%s] not found", id))); } @PostMapping(consumes = APPLICATION_JSON_VALUE) public ResponseEntity<Void> createNewUser(@RequestBody @Validated User user, UriComponentsBuilder uriComponentsBuilder) { User addedUser = this.userService.addNewUser(user) .orElseThrow(() -> new UserAlreadyExistsException( String.format("User with id [%s] is already present", user.getId()))); UriComponents uriComponents = uriComponentsBuilder.path("/api/users/{id}").buildAndExpand(addedUser.getId()); return ResponseEntity.created(uriComponents.toUri()).build(); } @DeleteMapping("/{id}") public void deleteUser(@PathVariable("id") Long id) { this.userService.deleteUserById(id); } } |
For the sake of simplicity, the UserService
stores the available users in-memory:
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 | @Service public class UserService { private List<User> userList; @PostConstruct public void init() { this.userList = new ArrayList(); userList.add(new User(1L, "duke", Set.of("java", "jdk", "spring"))); userList.add(new User(2L, "spring", Set.of("spring", "cloud", "boot"))); userList.add(new User(3L, "boot", Set.of("boot", "kotlin", "spring"))); } public List<User> getAllUsers() { return this.userList; } public Optional<User> getUserById(Long id) { return this.userList.stream() .filter(user -> user.getId() == id) .findFirst(); } public Optional<User> addNewUser(User user) { if (this.getUserById(user.getId()).isPresent()) { return Optional.empty(); } this.userList.add(user); return Optional.of(user); } public void deleteUserById(Long id) { this.userList.removeIf(user -> user.getId() == id); } } |
We'll now test some of the corner cases of this API (e.g., creating a user with an already existing id) and also the happy-path in the following section using the WebTestClient
.
Using the WebTestClient for Integration Tests
With the Spring WebTestClient, we can fire HTTP requests against our running application during integration tests. Spring Boot autoconfigures a WebTestClient
bean for us, once our test uses: @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
(further configuration options of @SpringBootTest).
The client is already autoconfigured, and the base URL points to our locally running Spring Boot. There's no need to inject the random port and configure the client.
In addition to this, we can bootstrap the WebTestClient
with its builder manually. This allows to, e.g., bind the client to a specific controller or route:
1 | WebTestClient testClient = WebTestClient.bindToController(UserController.class).build(); |
We'll use the autoconfigured version of the WebTestClient
in the following examples. We can inject this bean with @Autowired
within our test class:
1 2 3 4 5 6 7 8 9 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class UserControllerIT { @Autowired private WebTestClient webTestClient; // ... } |
Given this bean, let's write the first test to verify our three default users are returned:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @Test void shouldReturnThreeDefaultUsers(){ this.webTestClient .get() .uri("/api/users") .header(ACCEPT,APPLICATION_JSON_VALUE) .exchange() .expectStatus() .is2xxSuccessful() .expectHeader() .contentType(APPLICATION_JSON) .expectBody() .jsonPath("$.length()").isEqualTo(3) .jsonPath("$[0].id").isEqualTo(1) .jsonPath("$[0].name").isEqualTo("duke") .jsonPath("$[0].tags").isNotEmpty(); } |
As the base URL with the random port of our Spring application is already configured, we just have to pass the URI we want to create a request for. If you are familiar with the WebClient
, you'll notice that the API of this WebTestClient
is quite similar.
The main difference is the capability of asserting different things after we call .exchange()
(recall exchange vs. retrieve). We can expect different parts of the response in a fluent manner: the status code, response headers, and the body.
We are not limited only to write jsonPath
expressions for verifying the body. We can also use xpath
or parse the result to a POJO, as we'll see later on.
In the same way, we can write tests to verify corner cases like requesting an unknown user or asking for XML as a content type:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | @Test void shouldReturnNotFoundForUnknownUserId() { this.webTestClient .get() .uri("/api/users/{id}", 42) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(NOT_FOUND); } @Test void shouldNotSupportMediaTypeXML() { this.webTestClient .get() .uri("/api/users") .header(ACCEPT, APPLICATION_XML_VALUE) .exchange() .expectStatus() .isEqualTo(NOT_ACCEPTABLE); } |
Advanced Usage of the WebTestClient
Next, let's write a more advanced integration test and work with the response of the WebTestClient
. For this, we'll write a test to verify the flow of creating a new user, querying for it, and then deleting the user again, works:
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 | @Test void shouldCreateNewUser() { var newUser = new User(10L, "test", Set.of("testing", "webtestclient")); var userCreationResponse = this.webTestClient .post() .uri("/api/users") .body(Mono.just(newUser), User.class) .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(CREATED) .returnResult(Void.class); var locationUrlOfNewUser = userCreationResponse.getResponseHeaders().get(LOCATION).get(0); this.webTestClient .get() .uri(locationUrlOfNewUser) .header(ACCEPT, APPLICATION_JSON_VALUE) .exchange() .expectStatus() .isEqualTo(OK) .expectBody(User.class) .isEqualTo(newUser); this.webTestClient .delete() .uri("/api/users/{id}", newUser.getId()) .exchange(); } |
With .returnResult()
you can return the response to a local variable and work with it. We need this to access the Location
header of the response.
Similar to this, we can expect the body of the response to match an object. We see this within the second WebTestClient
call, where we parse the response to a User
object and expect it to be equal with the newUser
object. Make sure to implement .equals()
and .hashCode()
for this to work properly.
Further Resources on Spring's WebClient
To learn more about the WebClient
, have a look at the following blog posts:
- Use Spring WebClient for RESTful communication
- Spring WebClient OAuth2 Integration for Spring: WebFlux & (Servlet)
- Writing Unit Tests for the Spring WebClient with MockWebServer from OkHttp
The source code for this sample REST API application, including the tests, is available on GitHub.
For further tips & tricks when it comes to testing Spring Boot Applications, consider enrolling in the Spring Boot Testing Applications Masterclass. The Masterclass is a deep-dive online course with practical hands-on advice for testing real-world (AWS, React, PostgreSQL, OIDC with Keycloak, etc.) Spring Boot applications.
Have fun using the Spring WebTestClient,
Phil