Spring Cloud Gateway Testing: Route Validation, Filter Testing, and WireMock Contracts

Spring Cloud Gateway Testing: Route Validation, Filter Testing, and WireMock Contracts

An API gateway is the front door to your microservices architecture. Every request passes through it — authentication checks, rate limiting, request transformation, circuit breaking, routing. If the gateway misbehaves, every service behind it is affected. Yet gateways are frequently undertested because developers assume the framework handles everything correctly, and testing routing logic across multiple services feels complex.

Spring Cloud Gateway's testing story is built on WebTestClient, WireMock for mocking downstream services, and Spring Cloud Contract for consumer-driven contract testing. This guide walks through a comprehensive testing strategy for your gateway.

Project Setup

A Spring Cloud Gateway test setup requires the gateway dependency and test utilities:

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>

    <!-- Test dependencies -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.wiremock</groupId>
        <artifactId>wiremock-standalone</artifactId>
        <version>3.4.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Basic Route Testing with WebTestClient

The most fundamental gateway test verifies that a request to the gateway's route ends up at the right downstream service. Use @SpringBootTest with a random port to start the full gateway:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GatewayRoutingTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension articleService = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @RegisterExtension
    static WireMockExtension userService = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8082))
        .build();

    @Test
    void requestToArticleRoute_isForwardedToArticleService() {
        articleService.stubFor(get(urlEqualTo("/articles/spring-guide"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {"slug": "spring-guide", "title": "Spring Guide"}
                    """)));

        webTestClient.get()
            .uri("/api/articles/spring-guide")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
                .jsonPath("$.slug").isEqualTo("spring-guide")
                .jsonPath("$.title").isEqualTo("Spring Guide");

        articleService.verify(getRequestedFor(urlEqualTo("/articles/spring-guide")));
    }

    @Test
    void requestToUserRoute_isForwardedToUserService() {
        userService.stubFor(get(urlEqualTo("/users/alice"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {"username": "alice", "email": "alice@example.com"}
                    """)));

        webTestClient.get()
            .uri("/api/users/alice")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
                .jsonPath("$.username").isEqualTo("alice");

        userService.verify(getRequestedFor(urlEqualTo("/users/alice")));
    }

    @Test
    void unknownRoute_returns404() {
        webTestClient.get()
            .uri("/api/unknown-service/resource")
            .exchange()
            .expectStatus().isNotFound();
    }
}

The gateway configuration (application.yml) for these tests:

spring:
  cloud:
    gateway:
      routes:
        - id: article-service
          uri: http://localhost:8081
          predicates:
            - Path=/api/articles/**
          filters:
            - StripPrefix=1

        - id: user-service
          uri: http://localhost:8082
          predicates:
            - Path=/api/users/**
          filters:
            - StripPrefix=1

Testing Custom GatewayFilter Implementations

Custom filters are where your cross-cutting logic lives. Test them in isolation first, then as part of the gateway:

// The filter under test
@Component
public class RequestIdGatewayFilter implements GatewayFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String requestId = exchange.getRequest().getHeaders()
            .getFirst("X-Request-Id");

        if (requestId == null) {
            requestId = UUID.randomUUID().toString();
            exchange = exchange.mutate()
                .request(r -> r.header("X-Request-Id", requestId))
                .build();
        }

        String finalRequestId = requestId;
        return chain.filter(exchange)
            .then(Mono.fromRunnable(() ->
                exchange.getResponse().getHeaders().add("X-Request-Id", finalRequestId)
            ));
    }

    @Override
    public int getOrder() { return -1; }
}

Integration test for the filter:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RequestIdFilterTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension downstream = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @BeforeEach
    void setUp() {
        downstream.stubFor(get(anyUrl())
            .willReturn(aResponse().withStatus(200).withBody("{}")));
    }

    @Test
    void filterAddsRequestIdHeaderWhenMissing() {
        webTestClient.get()
            .uri("/api/articles/test")
            .exchange()
            .expectHeader().exists("X-Request-Id");

        // Verify downstream received the header
        downstream.verify(getRequestedFor(anyUrl())
            .withHeader("X-Request-Id", matching("[0-9a-f-]{36}")));
    }

    @Test
    void filterPreservesExistingRequestId() {
        String existingId = "my-custom-request-id-123";

        webTestClient.get()
            .uri("/api/articles/test")
            .header("X-Request-Id", existingId)
            .exchange()
            .expectHeader().valueEquals("X-Request-Id", existingId);

        downstream.verify(getRequestedFor(anyUrl())
            .withHeader("X-Request-Id", equalTo(existingId)));
    }
}

Testing GlobalFilter Implementations

GlobalFilter applies to all routes. Test that it doesn't interfere with route-specific filters and handles edge cases:

@Component
public class AuthenticationGlobalFilter implements GlobalFilter, Ordered {

    private final JwtValidator jwtValidator;

    public AuthenticationGlobalFilter(JwtValidator jwtValidator) {
        this.jwtValidator = jwtValidator;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        String token = authHeader.substring(7);
        return jwtValidator.validate(token)
            .flatMap(claims -> {
                exchange = exchange.mutate()
                    .request(r -> r.header("X-User-Id", claims.getSubject())
                                   .header("X-User-Roles", String.join(",", claims.getRoles())))
                    .build();
                return chain.filter(exchange);
            })
            .onErrorResume(InvalidTokenException.class, e -> {
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            });
    }

    @Override
    public int getOrder() { return -2; }
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthenticationGlobalFilterTest {

    @Autowired
    private WebTestClient webTestClient;

    @MockBean
    private JwtValidator jwtValidator;

    @RegisterExtension
    static WireMockExtension downstream = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @BeforeEach
    void setUp() {
        downstream.stubFor(get(anyUrl())
            .willReturn(aResponse().withStatus(200).withBody("{}")));
    }

    @Test
    void requestWithoutToken_isRejectedWith401() {
        webTestClient.get()
            .uri("/api/articles/test")
            .exchange()
            .expectStatus().isUnauthorized();

        downstream.verify(0, getRequestedFor(anyUrl())); // never reached downstream
    }

    @Test
    void requestWithValidToken_forwardsWithUserHeaders() {
        JwtClaims claims = new JwtClaims("user-123", List.of("ROLE_USER"));
        when(jwtValidator.validate("valid-token")).thenReturn(Mono.just(claims));

        webTestClient.get()
            .uri("/api/articles/test")
            .header("Authorization", "Bearer valid-token")
            .exchange()
            .expectStatus().isOk();

        downstream.verify(getRequestedFor(anyUrl())
            .withHeader("X-User-Id", equalTo("user-123"))
            .withHeader("X-User-Roles", equalTo("ROLE_USER")));
    }

    @Test
    void requestWithExpiredToken_returns401() {
        when(jwtValidator.validate("expired-token"))
            .thenReturn(Mono.error(new InvalidTokenException("Token expired")));

        webTestClient.get()
            .uri("/api/articles/test")
            .header("Authorization", "Bearer expired-token")
            .exchange()
            .expectStatus().isUnauthorized();
    }
}

Testing Request and Response Transformation Filters

Transformation filters modify request/response bodies or headers. Test both the transformation logic and that it doesn't corrupt the payload:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ResponseTransformationFilterTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension downstream = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @Test
    void addCorrelationIdFilter_addsHeaderToResponse() {
        downstream.stubFor(get(urlEqualTo("/articles/1"))
            .willReturn(aResponse()
                .withStatus(200)
                .withBody("{\"id\": 1}")));

        webTestClient.get()
            .uri("/api/articles/1")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().exists("X-Correlation-Id")
            .expectHeader().exists("X-Gateway-Timestamp");
    }

    @Test
    void stripSensitiveHeaderFilter_removesInternalHeaders() {
        downstream.stubFor(get(urlEqualTo("/articles/1"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("X-Internal-Auth-Token", "secret-value")
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\": 1}")));

        webTestClient.get()
            .uri("/api/articles/1")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().doesNotExist("X-Internal-Auth-Token") // stripped by filter
            .expectHeader().exists("Content-Type");
    }

    @Test
    void requestBodyTransformFilter_normalizesPayload() {
        downstream.stubFor(post(urlEqualTo("/articles"))
            .withRequestBody(matchingJsonPath("$.normalizedTitle"))
            .willReturn(aResponse()
                .withStatus(201)
                .withBody("{\"id\": 42}")));

        String rawBody = """
            {"title": "  Spring Boot Guide  ", "status": "DRAFT"}
            """;

        webTestClient.post()
            .uri("/api/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(rawBody)
            .exchange()
            .expectStatus().isCreated();

        // Filter should have normalized whitespace and added normalizedTitle
        downstream.verify(postRequestedFor(urlEqualTo("/articles"))
            .withRequestBody(matchingJsonPath("$.normalizedTitle", equalTo("Spring Boot Guide"))));
    }
}

Testing Rate Limiting Filters

Rate limiting tests require careful setup because the filter state depends on a Redis instance (or in-memory store for tests):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RateLimitingFilterTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension downstream = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @BeforeEach
    void setUp() {
        downstream.stubFor(get(anyUrl())
            .willReturn(aResponse().withStatus(200).withBody("{}")));
    }

    @Test
    void requestsWithinLimit_areAllowed() {
        // Rate limit configured to 5 requests per second for test profile
        for (int i = 0; i < 5; i++) {
            webTestClient.get()
                .uri("/api/articles/test")
                .header("X-Forwarded-For", "192.168.1.100")
                .exchange()
                .expectStatus().isOk();
        }
    }

    @Test
    void requestsExceedingLimit_areThrottled() {
        // Send 10 rapid requests; rate limit is 5/sec
        List<HttpStatus> statuses = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            HttpStatus status = webTestClient.get()
                .uri("/api/articles/test")
                .header("X-Forwarded-For", "192.168.1.200")
                .exchange()
                .returnResult(String.class)
                .getStatus();
            statuses.add(status);
        }

        long tooManyRequests = statuses.stream()
            .filter(s -> s == HttpStatus.TOO_MANY_REQUESTS)
            .count();

        assertThat(tooManyRequests).isGreaterThan(0);
    }

    @Test
    void rateLimitResponse_includesRetryAfterHeader() {
        // Exhaust the rate limit
        for (int i = 0; i < 6; i++) {
            webTestClient.get()
                .uri("/api/articles/test")
                .header("X-Forwarded-For", "192.168.1.300")
                .exchange();
        }

        webTestClient.get()
            .uri("/api/articles/test")
            .header("X-Forwarded-For", "192.168.1.300")
            .exchange()
            .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS)
            .expectHeader().exists("X-RateLimit-Remaining")
            .expectHeader().exists("X-RateLimit-Reset");
    }
}

Using WireMock as a Downstream Service Mock

WireMock lets you simulate various downstream service behaviors — slow responses, error states, partial failures:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GatewayWireMockTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension articleService = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @Test
    void gatewayHandles503FromDownstream() {
        articleService.stubFor(get(anyUrl())
            .willReturn(aResponse()
                .withStatus(503)
                .withBody("Service temporarily unavailable")));

        webTestClient.get()
            .uri("/api/articles/test")
            .exchange()
            .expectStatus().isEqualTo(HttpStatus.BAD_GATEWAY);
    }

    @Test
    void gatewayHandlesSlowDownstreamWithTimeout() {
        articleService.stubFor(get(anyUrl())
            .willReturn(aResponse()
                .withStatus(200)
                .withBody("{}")
                .withFixedDelay(6000))); // 6 second delay; gateway timeout is 5s

        webTestClient.get()
            .uri("/api/articles/test")
            .exchange()
            .expectStatus().isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
    }

    @Test
    void wireMock_verifiesExactRequestStructure() {
        articleService.stubFor(post(urlEqualTo("/articles"))
            .willReturn(aResponse()
                .withStatus(201)
                .withHeader("Location", "/articles/42")
                .withBody("{\"id\": 42}")));

        webTestClient.post()
            .uri("/api/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .header("Authorization", "Bearer test-token")
            .bodyValue("""
                {"title": "New Article", "status": "DRAFT"}
                """)
            .exchange()
            .expectStatus().isCreated();

        // Verify the exact shape of what the gateway forwarded
        articleService.verify(postRequestedFor(urlEqualTo("/articles"))
            .withHeader("Content-Type", containing("application/json"))
            .withHeader("X-User-Id", matching(".+")) // added by auth filter
            .withRequestBody(matchingJsonPath("$.title", equalTo("New Article"))));
    }
}

Testing Circuit Breaker Integration

The circuit breaker pattern prevents cascade failures. Test that the gateway opens the circuit on repeated failures and serves the fallback:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CircuitBreakerFilterTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension downstream = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @Test
    void circuitBreaker_servesGatewayFallbackWhenCircuitOpen() {
        // Make the downstream service consistently fail
        downstream.stubFor(get(anyUrl())
            .willReturn(aResponse().withStatus(500)));

        // Trip the circuit by sending enough requests to exceed the failure threshold
        for (int i = 0; i < 10; i++) {
            webTestClient.get()
                .uri("/api/articles/circuit-test")
                .exchange(); // some 500s, eventually circuit opens
        }

        // Once circuit is open, gateway should serve the fallback immediately
        webTestClient.get()
            .uri("/api/articles/circuit-test")
            .exchange()
            .expectStatus().isOk() // fallback returns 200 with cached/default response
            .expectBody()
                .jsonPath("$.fallback").isEqualTo(true);
    }

    @Test
    void circuitBreaker_fallbackEndpointReturnsDefaultResponse() {
        // Directly test the fallback controller
        webTestClient.get()
            .uri("/fallback/article-service")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
                .jsonPath("$.message").isEqualTo("Article service is temporarily unavailable")
                .jsonPath("$.fallback").isEqualTo(true);
    }
}

Testing Route Predicates

Route predicates control which requests match a route. Test edge cases like path patterns, header-based routing, and query parameter predicates:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RoutePredicateTest {

    @Autowired
    private WebTestClient webTestClient;

    @RegisterExtension
    static WireMockExtension v1Service = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8081))
        .build();

    @RegisterExtension
    static WireMockExtension v2Service = WireMockExtension.newInstance()
        .options(wireMockConfig().port(8082))
        .build();

    @BeforeEach
    void setUp() {
        v1Service.stubFor(get(anyUrl())
            .willReturn(aResponse().withStatus(200).withBody("{\"version\": \"v1\"}")));
        v2Service.stubFor(get(anyUrl())
            .willReturn(aResponse().withStatus(200).withBody("{\"version\": \"v2\"}")));
    }

    @Test
    void headerBasedRouting_routesToV2WhenAcceptV2Header() {
        webTestClient.get()
            .uri("/api/articles/test")
            .header("Accept-Version", "v2")
            .exchange()
            .expectStatus().isOk()
            .expectBody().jsonPath("$.version").isEqualTo("v2");

        v2Service.verify(1, getRequestedFor(anyUrl()));
        v1Service.verify(0, getRequestedFor(anyUrl()));
    }

    @Test
    void pathVersionRouting_routesV1Prefix() {
        webTestClient.get()
            .uri("/v1/api/articles/test")
            .exchange()
            .expectStatus().isOk()
            .expectBody().jsonPath("$.version").isEqualTo("v1");
    }

    @Test
    void methodPredicate_onlyMatchesGet() {
        // Route configured with Method=GET predicate
        webTestClient.post()
            .uri("/read-only/articles")
            .exchange()
            .expectStatus().isNotFound(); // POST doesn't match the read-only route
    }

    @Test
    void queryParamPredicate_routesBasedOnParam() {
        webTestClient.get()
            .uri("/api/articles?format=legacy")
            .exchange()
            .expectStatus().isOk()
            .expectBody().jsonPath("$.version").isEqualTo("v1");

        webTestClient.get()
            .uri("/api/articles")
            .exchange()
            .expectStatus().isOk()
            .expectBody().jsonPath("$.version").isEqualTo("v2"); // default route
    }
}

Contract Testing with Spring Cloud Contract

Spring Cloud Contract enables consumer-driven contract testing. The gateway is the consumer; downstream services are the producers. Define the contracts from the gateway's perspective:

// src/test/resources/contracts/article-service/get-article-by-slug.groovy
Contract.make {
    description "Get article by slug returns article details"

    request {
        method GET()
        url "/articles/spring-guide"
        headers {
            accept(applicationJson())
        }
    }

    response {
        status OK()
        headers {
            contentType(applicationJson())
        }
        body([
            slug   : "spring-guide",
            title  : "Spring Guide",
            status : "PUBLISHED"
        ])
    }
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureStubRunner(
    ids = "com.example:article-service:+:stubs:8081",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class GatewayContractTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void gatewayRoutesAndTransformsArticleResponse() {
        // StubRunner starts a WireMock server at 8081 with the article-service stubs
        webTestClient.get()
            .uri("/api/articles/spring-guide")
            .accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk()
            .expectBody()
                .jsonPath("$.slug").isEqualTo("spring-guide")
                .jsonPath("$.title").isEqualTo("Spring Guide");
    }
}

Key Testing Patterns

Always verify WireMock interactions. articleService.verify(getRequestedFor(...)) confirms the request actually reached the downstream mock. Without it, your test might pass because the route returned a cached response or the test data happened to match a different stub.

Test the full filter chain order. If filters have @Order or implement Ordered, test scenarios where order matters — authentication before rate limiting, request ID before correlation ID.

Use @DynamicPropertySource for dynamic ports. If your WireMock server uses a dynamic port, register the downstream URL dynamically so your gateway configuration picks it up.

Test empty and error responses separately. A 404 from a downstream service should not look the same as a 404 from the gateway itself — the gateway should distinguish its own routing failures from downstream failures.

Continuous Gateway Monitoring

The gateway is the single most critical component in a microservices system — a misconfigured route or a broken filter affects every service simultaneously. A deployment that accidentally removes a security filter or changes a route predicate can expose internal endpoints to the public internet.

HelpMeTest monitors your Spring Cloud Gateway continuously, running route validation, filter behavior tests, and contract verifications on every deployment. With automated monitoring at the gateway layer, you catch routing regressions, security misconfigurations, and contract violations before they cascade into production incidents across your entire microservices fleet.

Treat your API gateway with the same rigor as your business logic — because a gateway bug has the blast radius of every service it protects.

Read more