Thursday, January 26, 2023

Spring Boot - WebTestClient Gotcha . . .

The Scenario

Recently I had a scenario to write an integration test in a Spring Boot micro-service (say MyService) which hosts it's own data in it's own PostgreSQL database in order to verify it's data against another Spring Boot micro-service which hosted master data in it's own database. The database contains a specific entity data set (say Item), a fixed set of items, used for a specific purpose. Another micro-service (say OtherService) uses one of the end-points of MyService by passing entity ids (Item ids) for some processing that MyService is capable of. The OtherService hosts master data of those Entities (Items) in its own database. But, the data hosted for each entity by both services is entirely different, and each has its own business with it's own data set. Only ids are common. OtherService data is considered master data. So, a MyService integration test requires to make sure that there are no entities missing in it's database. OtherService, offers an end-point to get it's hosted Item entities.

Environment: Java 17, Spring Boot 3.0.2, maven 3.8.5 on macOS Catalina 10.15.7

So, WebTestClient seemed like better choice to leverage to make an API request and get the master list of Items and check it's database to see if the count of Items and list of Item ids match.

In MyService, there was an integration test already in place written to check most of it's lookup like database entities; one test method for testing each entity's data set. This was a natural integration test to extend by adding another integration test method for testing Item entity set. But this one goes beyond it's database, out to OtherService, making an API request to to get the master list of Items to verify against.

The Issue

Simply Auto-wiring WebTestClient like:
@Autowired privateWebTestClient webTestClient;

broke the auto-configuration by the following exception:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'com.hmhco.sgm.scoring.api.persistence.LookupRepositoriesDataIT': Unsatisfied dependency expressed through field 'webTestClient'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.test.web.reactive.server.WebTestClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.test.web.reactive.server.WebTestClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}

The Solution

A quick Googling suggested to try the following, which actually worked (NOTE: WebTestClient requires org.springframework.boot:spring-boot-starter-webflux dependency which we already had in MyService app pom.xml:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

But this solution brings up the embedded web server (Tomcat) starting it at a random port which is actually unnecessary for this test. All I need is an instance of WebTestClient to make a HTTP Get request to OtherService end-point.

Here is the code snippet for this solution:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Value("${spring.other-service-api.get-Items-end-point}") private String apiEndPoint; @Value("${spring.other-service-api.trusted_token}") private String trustedToken; @Autowired private WebTestClient webTestClient; ... @Test public void itemLookup_has_no_missing_items() throws Exception { // given: request spec var requestSpec = webTestClient.get() .uri(apiEndPoint) .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .header(HttpHeaders.AUTHORIZATION, trustedToken); // expect: end-point used to get items, succeeds and get JSON response body var response = requestSpec.exchange() .expectStatus().isOk() .expectBody(String.class) .returnResult() .getResponseBody(); // and: convery JSON response to List List<Map<String, String>> items = new ObjectMapper().readValue(response, List.class); // verify: items size matches with what we have in database ... // verify: item ids match with what we have in database ... }


Little more Research

But a follow-up reading of Spring Boot Documentation made me dig bit deeper. Actually, I did not need a full web environment to be up with embedded servers started and listening on random port that the above change brings in, which also satisfied auto-configuration need for WebTestClient.

Spring Boot auto configuration has a set of annotations with which you can pick and chose the ones that you really need. Apparently there is one for WebTestClient,  @AutoConfigureWebTestClient. That sounded like the way to go instead of making having the complete test web environment up and running.

So, thought the following would work, but it also failed with the above, same exception:
@SpringBootTest @AutoConfigureWebTestClient @AutoConfigureWebFlux @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Autowired private WebTestClient webTestClient; ... }

After little more investigation, I found that we do have dependency: spring-boot-starter-hateoas that Spring Boot Documentation clearly warns on this saying it is meant to be specifically for Spring MVC and should not be used with WebFlux. The alternative in this case is to use: org.springframework.hateoas:spring-hateoas instead. Well, tried that too with the above annotations, but ended up with the same exception.

Better and cleaner solution

The better and cleaner solution is not to have the embedded web server started, but WebTestClient instance created to make a HTTP Get request. So the obvious solution is to ditch dependency injection for WebTestClient and create an instance.

The following is the code snippet:
@SpringBootTest @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Value("${spring.other-service-api.get-Items-end-point}") private String apiEndPoint; @Value("${spring.other-service-api.trusted_token}") private String trustedToken; ... @Test public void itemLookup_has_no_missing_items() throws Exception { // given: web test client WebTestClient webTestClient = WebTestClient .bindToServer() .baseUrl(apiEndPoint) .build(); // and: request spec var requestSpec = webTestClient .get() .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .header(HttpHeaders.AUTHORIZATION, trustedToken); // expect: end-point used to get items, succeeds and get JSON response body var response = requestSpec.exchange() .expectStatus().isOk() .expectBody(String.class) .returnResult() .getResponseBody(); // and: convery JSON response to List List<Map<String, String>> items = new ObjectMapper().readValue(response, List.class); // verify: items size matches with what we have in database ... // verify: item ids match with what we have in database ... }

TIP

Sometimes, WebTestClient times out if the response takes more time than the default value 5000 milliseconds (5 seconds) by throwing the following exception:
java.lang.IllegalStateException: Timeout on blocking read for 5000 MILLISECONDS at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:123) at reactor.core.publisher.Mono.block(Mono.java:1734)

This timeout can be configured in two ways.

1. If WebTestClient is @Autowired with web server starting on a random port, by using annotation @AutoConfigureWebTestClient as shown below:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient(timeout = "10000") // millis, 10 seconds @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Value("${spring.other-service-api.get-Items-end-point}") private String apiEndPoint; @Value("${spring.other-service-api.trusted_token}") private String trustedToken; @Autowired private WebTestClient webTestClient; ... }

2. If WebTestClient is NOT @AutoWired and the embedded web server is not started, then by setting response timeout while building WebTestClient instance as shown below:
@Test public void itemLookup_has_no_missing_items() throws Exception { // given: web test client WebTestClient webTestClient = WebTestClient .bindToServer() .baseUrl(apiEndPoint) .build() .mutate() .responseTimeout(Duration.ofSeconds(10)) .build(); ... }

Conclusion

These days, most of the times, solutions can be found by googling or on stackoverflow. But, sometimes a clea(ne)r solution takes time to explore and try out.

A software developer's life is never easy, it's always challenging by simple things that look simple on the surface, yet complex under the hoods. Spring Boot framework is no exception in this regard ;)

Here is the link to GitHub repo that contains example code for two different integration test-cases of the two solutions mentioned in this post to try out: spring-boot-gotchas GitHub repo