Non-blocking, reactive client to perform HTTP requests, exposing a fluent, reactive API over underlying HTTP client libraries such as Reactor Netty.
In my current project I have been using WebClient extensively in making service to service calls and have found it to be an awesome API and I love its use of fluent interface.
Consider a remote service which returns a list of "Cities". A code using WebClient looks like this:
... import org.springframework.http.MediaType import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.bodyToFlux import org.springframework.web.util.UriComponentsBuilder import reactor.core.publisher.Flux import java.net.URI class CitiesClient( private val webClientBuilder: WebClient.Builder, private val citiesBaseUrl: String ) { fun getCities(): Flux<City> { val buildUri: URI = UriComponentsBuilder .fromUriString(citiesBaseUrl) .path("/cities") .build() .encode() .toUri() val webClient: WebClient = this.webClientBuilder.build() return webClient.get() .uri(buildUri) .accept(MediaType.APPLICATION_JSON) .exchange() .flatMapMany { clientResponse -> clientResponse.bodyToFlux<City>() } } }
It is difficult to test a client making use of WebClient though. In this post, I will go over the challenges in testing a client using WebClient and a clean solution.
Challenges in mocking WebClient
An effective unit test of the "CitiesClient" class would require mocking of WebClient and every method call in the fluent interface chain along these lines:val mockWebClientBuilder: WebClient.Builder = mock() val mockWebClient: WebClient = mock() whenever(mockWebClientBuilder.build()).thenReturn(mockWebClient) val mockRequestSpec: WebClient.RequestBodyUriSpec = mock() whenever(mockWebClient.get()).thenReturn(mockRequestSpec) val mockRequestBodySpec: WebClient.RequestBodySpec = mock() whenever(mockRequestSpec.uri(any<URI>())).thenReturn(mockRequestBodySpec) whenever(mockRequestBodySpec.accept(any())).thenReturn(mockRequestBodySpec) val citiesJson: String = this.javaClass.getResource("/sample-cities.json").readText() val clientResponse: ClientResponse = ClientResponse .create(HttpStatus.OK) .header("Content-Type","application/json") .body(citiesJson).build() whenever(mockRequestBodySpec.exchange()).thenReturn(Mono.just(clientResponse)) val citiesClient = CitiesClient(mockWebClientBuilder, "http://somebaseurl") val cities: Flux<City> = citiesClient.getCities()
This makes for an extremely flaky test as any change in the order of calls would result in new mocks that will need to be recorded.
Testing using real endpoints
An approach that works well is to bring up a real server that behaves like the target of a client. Two mock servers that work really well are mockwebserver in okhttp library and WireMock. An example with Wiremock looks like this:
import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.core.WireMockConfiguration import org.bk.samples.model.City import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.springframework.http.HttpStatus import org.springframework.web.reactive.function.client.WebClient import reactor.core.publisher.Flux import reactor.test.StepVerifier class WiremockWebClientTest { @Test fun testARemoteCall() { val citiesJson = this.javaClass.getResource("/sample-cities.json").readText() WIREMOCK_SERVER.stubFor(WireMock.get(WireMock.urlMatching("/cities")) .withHeader("Accept", WireMock.equalTo("application/json")) .willReturn(WireMock.aResponse() .withStatus(HttpStatus.OK.value()) .withHeader("Content-Type", "application/json") .withBody(citiesJson))) val citiesClient = CitiesClient(WebClient.builder(), "http://localhost:${WIREMOCK_SERVER.port()}") val cities: Flux<City> = citiesClient.getCities() StepVerifier .create(cities) .expectNext(City(1L, "Portland", "USA", 1_600_000L)) .expectNext(City(2L, "Seattle", "USA", 3_200_000L)) .expectNext(City(3L, "SFO", "USA", 6_400_000L)) .expectComplete() .verify() } companion object { private val WIREMOCK_SERVER = WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()) @BeforeAll @JvmStatic fun beforeAll() { WIREMOCK_SERVER.start() } @AfterAll @JvmStatic fun afterAll() { WIREMOCK_SERVER.stop() } } }
Here a server is being brought up at a random port, it is then injected with a behavior and then the client is tested against this server and validated. This approach works and there is no muddling with the internals of WebClient in mocking this behavior, but technically this is an integration test and it will be slower to execute than a pure unit test.
Unit testing by short-circuiting the remote call
An approach that I have been using recently is to short circuit the remote call using an ExchangeFunction. An ExchangeFunction represents the actual mechanisms in making the remote call and can be replaced with one that responds with what the test expects the following way:import org.junit.jupiter.api.Test import org.springframework.http.HttpStatus import org.springframework.web.reactive.function.client.ClientResponse import org.springframework.web.reactive.function.client.ExchangeFunction import org.springframework.web.reactive.function.client.WebClient import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.test.StepVerifier class CitiesWebClientTest { @Test fun testCleanResponse() { val citiesJson: String = this.javaClass.getResource("/sample-cities.json").readText() val clientResponse: ClientResponse = ClientResponse .create(HttpStatus.OK) .header("Content-Type","application/json") .body(citiesJson).build() val shortCircuitingExchangeFunction = ExchangeFunction { Mono.just(clientResponse) } val webClientBuilder: WebClient.Builder = WebClient.builder().exchangeFunction(shortCircuitingExchangeFunction) val citiesClient = CitiesClient(webClientBuilder, "http://somebaseurl") val cities: Flux<City> = citiesClient.getCities() StepVerifier .create(cities) .expectNext(City(1L, "Portland", "USA", 1_600_000L)) .expectNext(City(2L, "Seattle", "USA", 3_200_000L)) .expectNext(City(3L, "SFO", "USA", 6_400_000L)) .expectComplete() .verify() } }
The WebClient is injected with a ExchangeFunction which simply returns a response with the expected behavior of the remote server. This has short circuited the entire remote call and allows the client to be tested comprehensively. This approach depends on a little knowledge of the internals of the WebClient. This is a decent compromise though as it would run far faster than a test using WireMock.
This approach is not original though, I have based this test on some of the tests used for testing WebClient itself, for eg, the one here
No comments:
Post a Comment