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=1Testing 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.