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:

  1. Extend your test case with Spring extension
  2. Specify a configuration (components to be injected)
  3. Provide external mock dependencies (like service or repository)
  4. 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:

  1. Prepare a new product entity
  2. Configure mocks (service) behaviour
  3. Prepare the POST request
  4. Execute the request
  5. 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:

  1. Define a mock data entity and ID
  2. Configure the service behaviour to return data
  3. Create URI path: consists of two parts – base part and ID
  4. Execute the request
  5. 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:

  1. Generate a random ID
  2. Configure a mock service to return an empty response
  3. Execute the request
  4. 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.