Router functions play a similar role in Spring Webflux as controllers in Spring MVC. Both are responsible for handling HTTP requests/responses. However, compare to controllers, new approach is not represented by a single component, but consists of two parts: a router function and a handler, which serves as glue between service (business logic) and HTTP layer.
This means, that developers need to test Spring components as a group. This post overviews techniques of testing of Spring Webflux router functions using WebTestClient.
Define components under test
For this article we will use as an example a sample API, that can create, remove and retrieve products. You can find a complete source code in this repository. From a technical point of view, it consists of repository layer, service layer and web layer. The last one in its regard is separated into two blocks:
- Handler component
ProductHandler.java
- Router function component
Router.java
In this section we will overview their sample implementations. First, take a look on the handler code below:
@Component @AllArgsConstructor class ProductHandler { private ProductService service; Mono<ServerResponse> createProduct (ServerRequest request) { Mono<Product> body = request.bodyToMono(Product.class); Mono<Product> result = body.flatMap(b -> service.createProduct(b)); return ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .body(result, Product.class); } Mono<ServerResponse> removeProduct (ServerRequest request) { UUID id = UUID.fromString(request.pathVariable("id")); Mono<Void> result = service.removeProduct(id); return ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .body(result, Void.class); } Mono<ServerResponse> findOneProduct (ServerRequest request) { UUID id = UUID.fromString(request.pathVariable("id")); Mono<Product> result = service.findOneProduct(id); return result.flatMap(data -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(data)) .switchIfEmpty(ServerResponse.notFound().build()); } Mono<ServerResponse> findAllProductsInCategory (ServerRequest request) { String category = request.pathVariable("category"); Flux<Product> result = service.findAllProductsInCategory(category); return ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .body(result, Product.class); } }
As you see, the handler serves as a bridge between router and service layer. It contains a reference to the ProductService
, which is injected by Spring (you can find a concrete implementation in the mentioned repository).
The second part of API is a router. It creates a router function (which defines the products endpoint), that connects routes with concrete handler methods:
@Configuration class Router { @Bean RouterFunction<ServerResponse> productEndpoint (ProductHandler handler) { MediaType json = MediaType.APPLICATION_JSON; return RouterFunctions.route(POST("/products").and(accept(json)), handler::createProduct) .andRoute(DELETE("/products/{id}").and(accept(json)), handler::removeProduct) .andRoute(GET("/products/category/{category}").and(accept(json)), handler::findAllProductsInCategory) .andRoute(GET("/products/one/{id}").and(accept(json)), handler::findOneProduct); } }
Configure a test
Prior writing concrete cases, let have a moment to configure a test. Testing router functions in Soring Webflux requires following steps:
- Extend your test case with Spring extension
- Specify a configuration (components to be injected)
- Provide external mock dependencies (like service or repository)
- Create a WebTestClient and bind to a context
Take a look on the code snippet below:
@ExtendWith(SpringExtension.class) @ContextConfiguration(classes = {ProductHandler.class, Router.class}) @WebFluxTest class ProductHandlerTest { @Autowired private ApplicationContext context; @MockBean private ProductService service; private WebTestClient client; @BeforeEach void setup() { client = WebTestClient.bindToApplicationContext(context).build(); } // here go test cases... }
Case 1. Create a new product
The first test scenario is to create a new product item. It corresponds to POST
endpoint. From a technical point of view, a test flow is following:
- Prepare a new product entity
- Configure mocks (service) behaviour
- Prepare the POST request
- Execute the request
- Assert results
The code listing below demonstrates this test case implementation:
@Test void createProductRouteTest() { Product product = new Product(UUID.randomUUID(), "Lenovo Ideapad laptop", "electronics", BigDecimal.valueOf(700)); Mockito.when(service.createProduct(product)).thenReturn(Mono.just(product)); client.post().uri("/products") .accept(MediaType.APPLICATION_JSON) .body(Mono.just(product), Product.class) .exchange() .expectStatus().isOk() .expectBody(Product.class).value(result -> Assertions.assertThat(result).isEqualTo(product)); }
Case 2. Get a single product
The next scenario is to retrieve a single product by its ID. I separated it into two parts:
- Successful result = product exists
- Failed result = product not found
Let move to the code.
Case 2.1. Product exists
As it was defined already, the first test case means, that we should get a Product
entity back from the router function. Here we proceed through following steps:
- Define a mock data entity and ID
- Configure the service behaviour to return data
- Create URI path: consists of two parts – base part and ID
- Execute the request
- Assert results
Take a look on the implementation, presented below:
@Test void findOneProductRouteSuccessTest(){ Product product = new Product(UUID.randomUUID(), "Lenovo Ideapad laptop", "electronics", BigDecimal.valueOf(700)); UUID id = product.getProductId(); Mockito.when(service.findOneProduct(id)).thenReturn(Mono.just(product)); URI path = URI.create("/products/one/".concat(id.toString())); client.get().uri(path) .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus().isOk() .expectBody(Product.class).value(result -> Assertions.assertThat(result).isEqualTo(product)); }
Case 2.2. Product not found
In the previous test scenario we checked a scenario with an existing product. In case of an absence of data, the handler processes with 404 Not found
response. We already talked about an error handling in Spring Webflux in the previous post. The same logic applies here.
What do we need to accomplish here:
- Generate a random ID
- Configure a mock service to return an empty response
- Execute the request
- Assert that response has not found code
Below you can find the test case code:
@Test void findOneProductFailureRouteTest(){ UUID id = UUID.randomUUID(); Mockito.when(service.findOneProduct(id)).thenReturn(Mono.empty()); URI path = URI.create("/products/one/".concat(id.toString())); client.get().uri(path) .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus().isNotFound(); }
This is for this post about how to write tests for Spring Webflux router functions. As you could note, this is not something really hard, however there is a difference between traditional controller-based architecture and new router function paradigm. In this article I focused on tests alone, but you can find a complete code for the example application in this github repository. If you have questions, do not hesitate to contact me.