How do you test Spring Cloud Gateway's RouteLocator?

64 Views Asked by At

I want to test my RouteLocator. I tried to do it in a manner similar to how we test endpoints in a WebFluxTest. Here are my

Attempts

Attempt #1

package com.example.gatewaydemo.filter;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory;
import org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.UUID;

@WebFluxTest(controllers = GatewayTest.Config.class)
@ContextConfiguration(classes = GatewayTest.Config.class)
public class GatewayTest {
    @Autowired
    WebTestClient client;

    @Test
    void test() {
        client.get()
                .uri("/original-path")
                .exchange()
                .expectStatus().isEqualTo(HttpStatus.OK);
    }

    @Configuration
    @EnableAutoConfiguration
    static class Config {
        @Bean
        public RouterFunction<ServerResponse> routerFunction() {
            return RouterFunctions.route()
                    .GET("/eventual-path", request -> ServerResponse.status(HttpStatus.OK).build())
                    .build();
        }

        @Bean
        public RouteLocator routeLocator() {
            // suppose I want to test this RouteLocator
            GatewayFilter rewriteFilter = new RewritePathGatewayFilterFactory()
                    .apply(config -> config.setRegexp("/original-path")
                            .setReplacement("/eventual-path"));
            Route route = Route.async()
                    .id(UUID.randomUUID().toString())
                    .filter(new OrderedGatewayFilter(rewriteFilter, 0)) // it's important to wrap
                    .predicate(new PathRoutePredicateFactory()
                            .apply(config -> config.setPatterns(List.of("/original-path"))))
                    .uri("http://localhost:8080")
                    .build();
            return () -> Flux.just(route);
        }
    }
}
<?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>3.1.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>gatewaydemo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gatewaydemo</name>
    <description>gatewaydemo</description>
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

However, the test fails due to a 404

2024-03-19T18:11:02.607+03:00 ERROR 14904 --- [           main] o.s.t.w.reactive.server.ExchangeResult   : Request details for assertion failure:

> GET /original-path
> WebTestClient-Request-Id: [1]

No content

< 404 NOT_FOUND Not Found
< Content-Type: [application/json]
< Content-Length: [140]

{"timestamp":"2024-03-19T15:11:02.447+00:00","path":"/original-path","status":404,"error":"Not Found","message":null,"requestId":"37ea34a7"}

I strongly believe my RouteLocator is ignored. Maybe, it has to do with @WebFluxTest that is not designed to pick it up

Attempt #2

@SpringBootTest
@AutoConfigureWebTestClient
public class GatewayTest {
    // the rest is the same

The test still fails, but this time it's a 500. It seems the server doesn't listen on port 8080

2024-03-19T18:36:20.582+03:00 ERROR 8860 --- [ctor-http-nio-2] a.w.r.e.AbstractErrorWebExceptionHandler : [c0fdf3a]  500 Server Error for HTTP GET "/original-path"

io.netty.channel.AbstractChannel$AnnotatedConnectException: Connection refused: no further information: localhost/127.0.0.1:8080
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    *__checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ HTTP GET "/original-path" [ExceptionHandlingWebHandler]
Original Stack Trace:
Caused by: java.net.ConnectException: Connection refused: no further information
    at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
    at java.base/sun.nio.ch.Net.pollConnectNow(Net.java:672) ~[na:na]
    at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:946) ~[na:na]
    at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:337) ~[netty-transport-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:334) ~[netty-transport-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:776) ~[netty-transport-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) ~[netty-transport-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) ~[netty-transport-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) ~[netty-transport-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997) ~[netty-common-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) ~[netty-common-4.1.100.Final.jar:4.1.100.Final]
    at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) ~[netty-common-4.1.100.Final.jar:4.1.100.Final]
    at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]

2024-03-19T18:36:20.699+03:00 ERROR 8860 --- [           main] o.s.t.w.reactive.server.ExchangeResult   : Request details for assertion failure:

> GET /original-path
> WebTestClient-Request-Id: [1]

No content

< 500 INTERNAL_SERVER_ERROR Internal Server Error
< Content-Type: [application/json]
< Content-Length: [136]

{"timestamp":"2024-03-19T15:36:20.576+00:00","path":"/original-path","status":500,"error":"Internal Server Error","requestId":"c0fdf3a"}

Attempt #3

webEnvironment, RANDOM_PORT

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
public class GatewayTest {
    @Autowired
    WebTestClient client;

    @LocalServerPort
    int port;

    @Test
    void test() {
        client.get()
                .uri("/original-path")
                .exchange()
                .expectStatus().isEqualTo(HttpStatus.OK);
    }

    @Configuration
    @EnableAutoConfiguration
    class Config {
        // the same RouterFunction

        @Bean
        public RouteLocator routeLocator() {
            GatewayFilter rewriteFilter = new RewritePathGatewayFilterFactory()
                    .apply(config -> config.setRegexp("/original-path")
                            .setReplacement("/eventual-path"));
            Route route = Route.async()
                    .id(UUID.randomUUID().toString())
                    .filter(new OrderedGatewayFilter(rewriteFilter, 0))
                    .predicate(new PathRoutePredicateFactory()
                            .apply(config -> config.setPatterns(List.of("/original-path"))))
                    .uri("http://localhost:" + port)
                    .build();
            return () -> Flux.just(route);
        }
    }
}

A 404 again

2024-03-19T19:12:15.447+03:00 ERROR 3760 --- [           main] o.s.t.w.reactive.server.ExchangeResult   : Request details for assertion failure:

> GET /original-path
> WebTestClient-Request-Id: [1]

No content

< 404 NOT_FOUND Not Found
< Content-Type: [application/json]
< Content-Length: [140]

{"timestamp":"2024-03-19T16:12:15.328+00:00","path":"/original-path","status":404,"error":"Not Found","message":null,"requestId":"5930e136"}

Research

SO

I examined several answers on this topic

  1. "Unit test Spring Cloud Gateway RouteLocator customRouteLocator(RouteLocatorBuilder routeLocatorBuilder)". I don't like the solution. It either suggests testing a RouteLocator automatically created from inline properties
@RunWith(SpringRunner.class)
@SpringBootTest(properties = {
    "spring.cloud.gateway.routes[0].id=test",
    "spring.cloud.gateway.routes[0].uri=http://localhost:8081",
    "spring.cloud.gateway.routes[0].predicates[0]=Path=/test/**",
}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class NettyRoutingFilterTests {

or involves Spring Cloud Eureka (way to many dependencies for a non-integration test, in my opinion)

@ExtendWith( { SpringExtension.class } )
@SpringBootTest(classes = { MockConfigurer.class },
webEnvironment = WebEnvironment.RANDOM_PORT )
public class RoutingIT
@Configuration
public class MockConfigurer
{
    private List<ServiceInstance> services;

    public MockConfigurer( List<ServiceInstance> services)
    {
        this.services= services;
    }

    @Bean
    public DiscoveryClient discoveryClient( )
    {
  1. "Spring Integration Test not working with Spring Boot 3 and Spring Cloud Gateway". It's not clear how the solution works, specifically where WebTestClient is pulled from
@SpringBootTest(classes = MainApplication.class, webEnvironment = DEFINED_PORT)
public class HealthControllerTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    public void health_shouldReturnHttp200() { this.webTestClient.get().uri(PATH).accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk();
    }
}

Doc

I visited the project's documentation page, but haven't found a specific chapter on testing

1

There are 1 best solutions below

0
Powet On

I found a way (likely not the way) to write a working test. The key is to use a real WebClient instead of WebTestClient

package com.example.gatewaydemo.filter;

import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory;
import org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.List;
import java.util.UUID;

@SpringBootTest(classes = GatewayTest.GatewayTestConfig.class,
        webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
        properties = "server.port=8080")
public class GatewayTest {
    @LocalServerPort
    int port;

    @Test
    void test() {
        Mono<ResponseEntity<Void>> responseEntityMono = WebClient.builder()
                .baseUrl("http://localhost:" + port)
                .build()
                .get()
                .uri("/original-path")
                .retrieve()
                .toBodilessEntity();
        StepVerifier.create(responseEntityMono)
                .expectNextMatches(r -> r.getStatusCode().is2xxSuccessful())
                .verifyComplete();
    }

    @Configuration
    @EnableAutoConfiguration
    static class GatewayTestConfig {
        @Bean
        public RouterFunction<ServerResponse> routerFunction() {
            return RouterFunctions.route()
                    .GET("/eventual-path", request -> ServerResponse.status(HttpStatus.OK).build())
                    .build();
        }

        @Bean
        public RouteLocator myRouteLocator() {
            GatewayFilter rewriteFilter = new RewritePathGatewayFilterFactory()
                    .apply(config -> config.setRegexp("/original-path")
                            .setReplacement("/eventual-path"));
            Route route = Route.async()
                    .id(UUID.randomUUID().toString())
                    .filter(new OrderedGatewayFilter(rewriteFilter, 0))
                    .predicate(new PathRoutePredicateFactory()
                            .apply(config -> config.setPatterns(List.of("/original-path"))))
                    .uri("http://localhost:8080")
                    .build();
            return () -> Flux.just(route);
        }
    }
}
<!-- for StepVerifier -->

<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-test</artifactId>
    <scope>test</scope>
</dependency>

Since @SpringBootTest looks for nested @Configurations anyway, explicitly referencing it in classes is optional

// this works too
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
        properties = "server.port=8080")