P5 — BFF Observability & Testing

P5 — BFF Observability & Testing

Related phases: P1-BFF-Foundations | P2-Spring-Boot-BFF-Stack | P3-BFF-Implementation-Patterns | P4-BFF-Security

Topics produced: Distributed-Tracing | Micrometer | Consumer-Driven-Contract-Testing | WireMock


OBS-01: Distributed Tracing with Micrometer Tracing

Spring Cloud Sleuth Is Dead

Critical: Spring Cloud Sleuth was the tracing solution for Spring Boot 2.x. It is not compatible with Spring Boot 3.x and has been discontinued. In Boot 3.x, the replacement is Micrometer Tracing, which provides a vendor-neutral tracing facade, analogous to what SLF4J does for logging.

Micrometer Tracing ships as part of the broader Micrometer observability stack and integrates directly with Spring Boot 3's auto-configuration. Do not add spring-cloud-sleuth to any Boot 3.x project — the dependency will not resolve from current Spring Cloud BOMs.

See Micrometer for the full concept note.

Dependencies (build.gradle)

dependencies {
    // Spring Boot core
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
 
    // Micrometer Tracing — OTel bridge (sends traces as OpenTelemetry)
    implementation 'io.micrometer:micrometer-tracing-bridge-otel'
 
    // OpenTelemetry exporter — Zipkin
    implementation 'io.opentelemetry:opentelemetry-exporter-zipkin'
 
    // Alternative: Jaeger via OTLP exporter
    // implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
 
    // Reactor instrumentation — required for reactive trace propagation
    implementation 'io.micrometer:micrometer-tracing'
    implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.5'
 
    // Metrics
    implementation 'io.micrometer:micrometer-registry-prometheus'
 
    // Spring Cloud Gateway (already pulls in reactor-netty)
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
}

application.yml — Complete Tracing Configuration

management:
  tracing:
    sampling:
      probability: 1.0          # 100% sampling in dev; set 0.1 (10%) in prod
  zipkin:
    tracing:
      endpoint: http://localhost:9411/api/v2/spans
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics, traces
  metrics:
    tags:
      application: ${spring.application.name}
 
spring:
  application:
    name: bff-gateway

For Jaeger via OTLP, replace the zipkin exporter dependency and set:

management:
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces

Auto-Configuration Behaviour

When micrometer-tracing-bridge-otel is on the classpath, Spring Boot 3 auto-configures:

  • A Tracer bean (Micrometer facade) backed by the OTel SDK
  • An ObservationRegistry bean that instruments WebClient, WebTestClient, Spring MVC/WebFlux server side
  • Automatic traceId and spanId injection into log output (via MDC where possible, via Reactor context in WebFlux — see OBS-04)
  • HTTP server observations: every incoming request becomes a root span
  • HTTP client observations: every WebClient call becomes a child span automatically

The BFF does not need manual instrumentation for the happy path — the gateway request and every downstream WebClient call are traced without any application code.

Manual Span Creation

For custom business logic (e.g., aggregation steps) inject the Tracer bean:

import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import reactor.core.publisher.Mono;
 
@Service
public class DashboardAggregationService {
 
    private final Tracer tracer;
    private final UserServiceClient userClient;
    private final OrderServiceClient orderClient;
 
    public DashboardAggregationService(Tracer tracer,
                                       UserServiceClient userClient,
                                       OrderServiceClient orderClient) {
        this.tracer = tracer;
        this.userClient = userClient;
        this.orderClient = orderClient;
    }
 
    public Mono<DashboardResponse> getDashboard(String userId) {
        Span aggregationSpan = tracer.nextSpan()
            .name("bff.dashboard.aggregation")
            .tag("userId", userId)
            .start();
 
        return Mono.zip(
                userClient.getUser(userId),
                orderClient.getOrders(userId)
            )
            .map(tuple -> new DashboardResponse(tuple.getT1(), tuple.getT2()))
            .doFinally(signal -> aggregationSpan.end())
            .contextWrite(context ->
                // Propagate the span into the Reactor context
                context.put(Span.class, aggregationSpan)
            );
    }
}

W3C Trace Context Headers

The W3C Trace Context specification defines two headers:

  • traceparent: 00-<traceId>-<parentSpanId>-<flags> (e.g., 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01)
  • tracestate: vendor-specific additional state (optional, often empty)

When an Angular SPA sends a request to the BFF with a traceparent header, Spring Cloud Gateway (with Micrometer Tracing on the classpath) automatically:

  1. Reads the incoming traceparent and extracts the parent trace/span IDs
  2. Creates a child span belonging to that same trace
  3. Forwards a new traceparent (with the BFF's span as parent) to downstream services

This means an Angular-originated trace can span: Angular -> BFF -> User Service -> Order Service, all under a single traceId, visible as one waterfall in Zipkin/Jaeger.


OBS-02: Trace Context Propagation from Angular

Angular HttpInterceptor for traceparent

The BFF relies on Angular adding a traceparent header so that end-to-end traces start from the browser. Install the W3C Trace Context interceptor:

// trace-context.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
 
@Injectable()
export class TraceContextInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Generate W3C traceparent: 00-<32 hex traceId>-<16 hex parentId>-01
    const traceId = this.generateHex(32);
    const spanId  = this.generateHex(16);
    const traceparent = `00-${traceId}-${spanId}-01`;
 
    const cloned = req.clone({
      headers: req.headers.set('traceparent', traceparent)
    });
    return next.handle(cloned);
  }
 
  private generateHex(length: number): string {
    return Array.from(crypto.getRandomValues(new Uint8Array(length / 2)))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }
}

Register in app.module.ts (or providers array in standalone apps):

providers: [
  { provide: HTTP_INTERCEPTORS, useClass: TraceContextInterceptor, multi: true }
]

Correlation ID GlobalFilter

The correlation ID is a simpler application-level identifier (UUID) complementing the distributed trace ID. While traceId is managed by the tracing infrastructure, X-Correlation-ID is an explicit header that non-tracing-aware services and logs can use for request correlation.

package com.example.bff.filter;
 
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
 
import java.util.UUID;
 
@Component
public class CorrelationIdFilter implements GlobalFilter, Ordered {
 
    public static final String CORRELATION_ID_HEADER = "X-Correlation-ID";
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String correlationId = exchange.getRequest().getHeaders()
            .getFirst(CORRELATION_ID_HEADER);
 
        if (correlationId == null || correlationId.isBlank()) {
            correlationId = UUID.randomUUID().toString();
        }
 
        final String finalCorrelationId = correlationId;
 
        ServerWebExchange mutatedExchange = exchange.mutate()
            .request(r -> r.header(CORRELATION_ID_HEADER, finalCorrelationId))
            // Add to response so the Angular client can log it
            .build();
 
        // Add to response headers after chain completes
        mutatedExchange.getResponse().getHeaders()
            .add(CORRELATION_ID_HEADER, finalCorrelationId);
 
        // Store in Reactor context for MDC propagation (see OBS-04)
        return chain.filter(mutatedExchange)
            .contextWrite(ctx -> ctx.put(CORRELATION_ID_HEADER, finalCorrelationId));
    }
 
    @Override
    public int getOrder() {
        // Run before all other filters — negative order means early
        return -1;
    }
}

How Gateway Propagates Trace Headers

Spring Cloud Gateway, when Micrometer Tracing is present, uses HttpClientObservationConvention and the OTel W3CPropagator to inject traceparent and tracestate into every proxied request automatically. No manual header copying is required for the W3C headers — only the application-level X-Correlation-ID needs the GlobalFilter.


OBS-03: Metrics with Micrometer + Prometheus

What to Measure in a BFF

A BFF has distinct metric categories not present in a typical microservice:

MetricTypeDescription
http.server.requestsTimerAuto: per-route latency, status codes, throughput
spring.cloud.gateway.requestsTimerSCG: route-level metrics (route ID tag)
resilience4j.circuitbreaker.callsCounterCB state transitions and call outcomes
resilience4j.circuitbreaker.stateGaugeCurrent CB state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
bff.aggregation.callsCounterCustom: how often each aggregation runs
bff.aggregation.latencyTimerCustom: end-to-end aggregation time
bff.downstream.errorsCounterCustom: downstream service error rate by service

Actuator Configuration for Prometheus

management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true        # /actuator/health/liveness and /readiness
  metrics:
    export:
      prometheus:
        enabled: true
    tags:
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}

The Prometheus endpoint is available at /actuator/prometheus. Point a Prometheus scrape job at it:

# prometheus.yml scrape config
scrape_configs:
  - job_name: 'bff-gateway'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['bff-host:8080']
    scrape_interval: 15s

Custom Metrics with MeterRegistry

package com.example.bff.service;
 
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
 
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
 
@Service
public class DashboardAggregationService {
 
    private final Counter aggregationCalls;
    private final Timer aggregationTimer;
    private final Counter downstreamErrors;
 
    public DashboardAggregationService(MeterRegistry registry) {
        this.aggregationCalls = registry.counter(
            "bff.aggregation.calls",
            "type", "dashboard"
        );
        this.aggregationTimer = Timer.builder("bff.aggregation.latency")
            .tag("type", "dashboard")
            .description("End-to-end latency for dashboard aggregation")
            .publishPercentiles(0.5, 0.95, 0.99)
            .register(registry);
        this.downstreamErrors = registry.counter(
            "bff.downstream.errors",
            "service", "dashboard"
        );
    }
 
    public Mono<DashboardResponse> getDashboard(String userId) {
        aggregationCalls.increment();
        long start = System.nanoTime();
 
        return doAggregate(userId)
            .doOnSuccess(r -> aggregationTimer.record(
                Duration.ofNanos(System.nanoTime() - start)
            ))
            .doOnError(e -> {
                downstreamErrors.increment();
                aggregationTimer.record(
                    Duration.ofNanos(System.nanoTime() - start)
                );
            });
    }
 
    private Mono<DashboardResponse> doAggregate(String userId) {
        // actual aggregation logic
        return Mono.empty();
    }
}

Grafana Dashboard — Key Panels

A useful BFF Grafana dashboard has these panels:

  1. Request Raterate(http_server_requests_seconds_count[1m]) grouped by uri
  2. Error Raterate(http_server_requests_seconds_count{status=~"5.."}[1m]) / total
  3. Latency P95histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))
  4. Gateway Route Latencyrate(spring_cloud_gateway_requests_seconds_sum[1m]) by routeId
  5. Circuit Breaker Stateresilience4j_circuitbreaker_state (stat panel showing OPEN/CLOSED)
  6. Aggregation Latency P99histogram_quantile(0.99, rate(bff_aggregation_latency_seconds_bucket[5m]))
  7. Downstream Error Raterate(bff_downstream_errors_total[5m]) by service
  8. JVM Heapjvm_memory_used_bytes{area="heap"} (standard)

OBS-04: Structured Logging

The WebFlux MDC Problem

In a traditional thread-per-request model (Spring MVC), MDC works by storing values in a ThreadLocal. Every log statement on that thread automatically includes the stored traceId, correlationId, etc.

WebFlux uses a small thread pool and multiple requests share threads. A reactive operator like .flatMap() may execute on a different thread than where the request started. This means ThreadLocal-based MDC values are lost at thread hops.

Solution: Store logging context in the Reactor Context (which travels with the reactive chain) and extract it at each log point using Hooks.onEachOperator to copy Reactor context values into MDC before each operator executes.

Dependencies

// Structured JSON logging
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
 
  <springProfile name="!local">
    <!-- JSON output for production / staging -->
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <includeMdcKeyName>spanId</includeMdcKeyName>
        <includeMdcKeyName>correlationId</includeMdcKeyName>
        <customFields>{"application":"bff-gateway"}</customFields>
      </encoder>
    </appender>
 
    <root level="INFO">
      <appender-ref ref="JSON" />
    </root>
  </springProfile>
 
  <springProfile name="local">
    <!-- Human-readable for local dev -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%d{HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{36}) [%X{traceId}/%X{correlationId}] - %msg%n</pattern>
      </encoder>
    </appender>
 
    <root level="DEBUG">
      <appender-ref ref="CONSOLE" />
    </root>
  </springProfile>
 
</configuration>

This produces JSON like:

{
  "timestamp": "2026-03-07T14:23:01.234Z",
  "level": "INFO",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "logger": "c.e.bff.service.DashboardAggregationService",
  "message": "Aggregating dashboard for userId=42"
}

MDC Propagation in Reactive Pipelines

The MdcContextLifter pattern uses Hooks.onEachOperator to push Reactor context values into MDC before each operator runs, then clean up after:

package com.example.bff.logging;
 
import org.reactivestreams.Subscription;
import org.slf4j.MDC;
import reactor.core.CoreSubscriber;
import reactor.core.publisher.Hooks;
import reactor.core.publisher.Operators;
import reactor.util.context.Context;
 
import java.util.Map;
import java.util.stream.Collectors;
 
/**
 * Propagates Reactor Context entries with key prefix "mdc." into SLF4J MDC
 * so that structured log output includes traceId, spanId, correlationId.
 *
 * Register by calling MdcContextLifter.init() from a @PostConstruct or
 * ApplicationRunner bean.
 */
public class MdcContextLifter<T> implements CoreSubscriber<T> {
 
    public static final String MDC_CONTEXT_REACTOR_KEY = MdcContextLifter.class.getName();
 
    private final CoreSubscriber<T> coreSubscriber;
 
    public MdcContextLifter(CoreSubscriber<T> coreSubscriber) {
        this.coreSubscriber = coreSubscriber;
    }
 
    @Override
    public Context currentContext() {
        return coreSubscriber.currentContext();
    }
 
    @Override
    public void onSubscribe(Subscription s) {
        coreSubscriber.onSubscribe(s);
    }
 
    @Override
    public void onNext(T t) {
        copyToMdc(coreSubscriber.currentContext());
        try {
            coreSubscriber.onNext(t);
        } finally {
            MDC.clear();
        }
    }
 
    @Override
    public void onError(Throwable t) {
        copyToMdc(coreSubscriber.currentContext());
        try {
            coreSubscriber.onError(t);
        } finally {
            MDC.clear();
        }
    }
 
    @Override
    public void onComplete() {
        copyToMdc(coreSubscriber.currentContext());
        try {
            coreSubscriber.onComplete();
        } finally {
            MDC.clear();
        }
    }
 
    private void copyToMdc(Context context) {
        if (context.isEmpty()) return;
        Map<String, String> mdcEntries = context.stream()
            .filter(e -> e.getKey() instanceof String s && s.startsWith("mdc."))
            .collect(Collectors.toMap(
                e -> ((String) e.getKey()).substring(4),  // strip "mdc." prefix
                e -> String.valueOf(e.getValue())
            ));
        MDC.setContextMap(mdcEntries);
    }
 
    public static void init() {
        Hooks.onEachOperator(
            MDC_CONTEXT_REACTOR_KEY,
            Operators.lift((scannable, subscriber) ->
                new MdcContextLifter<>(subscriber)
            )
        );
    }
 
    public static void reset() {
        Hooks.resetOnEachOperator(MDC_CONTEXT_REACTOR_KEY);
    }
}

Register it in a @Configuration class:

package com.example.bff.config;
 
import com.example.bff.logging.MdcContextLifter;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class ReactorMdcConfig {
 
    @PostConstruct
    public void init() {
        MdcContextLifter.init();
    }
 
    @PreDestroy
    public void cleanup() {
        MdcContextLifter.reset();
    }
}

Then in filters, write into Reactor context with the mdc. prefix:

return chain.filter(mutatedExchange)
    .contextWrite(ctx -> ctx
        .put("mdc.correlationId", finalCorrelationId)
        .put("mdc.userId", resolvedUserId)
    );

Micrometer Tracing automatically writes mdc.traceId and mdc.spanId into the Reactor context when using the OTel bridge, so those appear in logs without extra wiring.


TEST-01: Testing Strategy for a BFF

A BFF has three distinct testing layers, each testing different concerns.

Layer 1 — Unit Tests (Isolated Filter and Service Logic)

Unit tests validate individual components in complete isolation. The key challenge is testing reactive code — use StepVerifier from reactor-test.

Testing a GlobalFilter:

package com.example.bff.filter;
 
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
 
class CorrelationIdFilterTest {
 
    private final CorrelationIdFilter filter = new CorrelationIdFilter();
 
    @Test
    void shouldGenerateCorrelationIdWhenAbsent() {
        MockServerHttpRequest request = MockServerHttpRequest
            .get("/api/dashboard/42")
            .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);
 
        GatewayFilterChain chain = mock(GatewayFilterChain.class);
        when(chain.filter(any())).thenReturn(Mono.empty());
 
        StepVerifier.create(filter.filter(exchange, chain))
            .verifyComplete();
 
        // The mutated exchange passed to chain.filter() should have the header
        // Verify via ArgumentCaptor
    }
 
    @Test
    void shouldPreserveExistingCorrelationId() {
        String existingId = "existing-correlation-id";
        MockServerHttpRequest request = MockServerHttpRequest
            .get("/api/dashboard/42")
            .header(CorrelationIdFilter.CORRELATION_ID_HEADER, existingId)
            .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);
 
        GatewayFilterChain chain = mock(GatewayFilterChain.class);
        when(chain.filter(any())).thenAnswer(inv -> {
            ServerWebExchange mutated = inv.getArgument(0);
            String id = mutated.getRequest().getHeaders()
                .getFirst(CorrelationIdFilter.CORRELATION_ID_HEADER);
            assertThat(id).isEqualTo(existingId);
            return Mono.empty();
        });
 
        StepVerifier.create(filter.filter(exchange, chain))
            .verifyComplete();
    }
}

Testing reactive aggregation with StepVerifier:

class DashboardAggregationServiceTest {
 
    private final UserServiceClient userClient = mock(UserServiceClient.class);
    private final OrderServiceClient orderClient = mock(OrderServiceClient.class);
    private final MeterRegistry registry = new SimpleMeterRegistry();
    private final Tracer tracer = mock(Tracer.class);
 
    private final DashboardAggregationService service =
        new DashboardAggregationService(tracer, userClient, orderClient, registry);
 
    @Test
    void shouldAggregateUserAndOrders() {
        UserResponse user = new UserResponse(42L, "Jane Doe", "jane@example.com");
        List<OrderResponse> orders = List.of(new OrderResponse("ord-1", 99.99));
 
        when(userClient.getUser("42")).thenReturn(Mono.just(user));
        when(orderClient.getOrders("42")).thenReturn(Flux.fromIterable(orders));
 
        StepVerifier.create(service.getDashboard("42"))
            .assertNext(dashboard -> {
                assertThat(dashboard.user().name()).isEqualTo("Jane Doe");
                assertThat(dashboard.orders()).hasSize(1);
                assertThat(dashboard.orders().get(0).id()).isEqualTo("ord-1");
            })
            .verifyComplete();
    }
 
    @Test
    void shouldPropagateErrorWhenUserServiceFails() {
        when(userClient.getUser("42")).thenReturn(
            Mono.error(new RuntimeException("User service unavailable"))
        );
 
        StepVerifier.create(service.getDashboard("42"))
            .verifyError(RuntimeException.class);
    }
}

Layer 2 — Integration Tests (Full BFF Stack with WireMock)

Integration tests spin up the full BFF application context against WireMock stubs for all downstream services. See TEST-02 for full WireMock examples.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")  // disables Redis session, relaxes security
@WireMockTest(httpPort = 8090)
class DashboardIntegrationTest {
    @Autowired
    private WebTestClient webTestClient;
    // test methods — see TEST-02
}

The test profile sets downstream base URLs to http://localhost:8090 (WireMock port) via application-test.yml:

# src/test/resources/application-test.yml
services:
  user-service:
    base-url: http://localhost:8090
  order-service:
    base-url: http://localhost:8090
 
spring:
  session:
    store-type: none   # disable Redis in tests
  security:
    oauth2:
      client:
        registration: {}  # disable OAuth2 for basic tests

Layer 3 — Contract Tests

Consumer-driven contract tests (Spring Cloud Contract) provide a formal API agreement between the BFF (consumer) and each downstream service (provider). See TEST-03 for full examples.


TEST-02: WireMock for Downstream Stubbing

Library Choice for Spring Boot 3

Use wiremock-spring-boot (from the WireMock project) — not the legacy WireMockRule (JUnit 4) or WireMockServer manual lifecycle management. The @WireMockTest annotation handles lifecycle automatically.

// build.gradle
testImplementation 'org.wiremock.integrations:wiremock-spring-boot:3.5.4'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'

Complete Integration Test

package com.example.bff.integration;
 
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
 
import static com.github.tomakehurst.wiremock.client.WireMock.*;
 
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@ActiveProfiles("test")
@WireMockTest(httpPort = 8090)
class DashboardAggregationIntegrationTest {
 
    @Autowired
    private WebTestClient webTestClient;
 
    @Test
    void shouldAggregateUserAndOrders(WireMockRuntimeInfo wmRuntimeInfo) {
        // Arrange — stub downstream services
        stubFor(get(urlEqualTo("/users/42"))
            .willReturn(okJson("""
                {
                  "id": 42,
                  "name": "Jane Doe",
                  "email": "jane@example.com"
                }
                """)));
 
        stubFor(get(urlPathEqualTo("/orders"))
            .withQueryParam("userId", equalTo("42"))
            .willReturn(okJson("""
                [
                  {"id": "ord-1", "total": 99.99, "status": "DELIVERED"},
                  {"id": "ord-2", "total": 49.50, "status": "PROCESSING"}
                ]
                """)));
 
        // Act + Assert
        webTestClient.get()
            .uri("/api/dashboard/42")
            .cookie("SESSION", "test-session-id")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.user.name").isEqualTo("Jane Doe")
            .jsonPath("$.user.email").isEqualTo("jane@example.com")
            .jsonPath("$.orders").isArray()
            .jsonPath("$.orders[0].id").isEqualTo("ord-1")
            .jsonPath("$.orders[1].id").isEqualTo("ord-2");
    }
 
    @Test
    void shouldReturnCorrelationIdInResponseHeader() {
        stubFor(get(urlEqualTo("/users/99")).willReturn(okJson("""
            {"id": 99, "name": "John", "email": "john@example.com"}
            """)));
        stubFor(get(urlPathEqualTo("/orders"))
            .withQueryParam("userId", equalTo("99"))
            .willReturn(okJson("[]")));
 
        webTestClient.get()
            .uri("/api/dashboard/99")
            .header("X-Correlation-ID", "my-custom-correlation-id")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().valueEquals("X-Correlation-ID", "my-custom-correlation-id");
    }
 
    @Test
    void shouldHandleDownstreamUserServiceFailure() {
        stubFor(get(urlEqualTo("/users/404"))
            .willReturn(notFound().withBody("""
                {"error": "User not found", "id": 404}
                """)));
 
        webTestClient.get()
            .uri("/api/dashboard/404")
            .exchange()
            .expectStatus().isNotFound();
    }
 
    @Test
    void shouldTriggerCircuitBreakerAfterRepeatedFailures() {
        // Stub to always fail
        stubFor(get(urlEqualTo("/users/500"))
            .willReturn(serverError()));
 
        // First few calls should fail with 502/503
        for (int i = 0; i < 5; i++) {
            webTestClient.get()
                .uri("/api/dashboard/500")
                .exchange()
                .expectStatus().is5xxServerError();
        }
 
        // After circuit opens, subsequent calls return fallback (if configured)
        webTestClient.get()
            .uri("/api/dashboard/500")
            .exchange()
            .expectStatus().isEqualTo(503);
    }
}

Verifying Downstream Was Called Correctly

WireMock's verify API confirms the BFF forwarded required headers downstream:

@Test
void shouldForwardAuthorizationHeaderToDownstream() {
    stubFor(get(urlEqualTo("/users/42"))
        .willReturn(okJson("""{"id": 42, "name": "Jane"}""")));
    stubFor(get(urlPathEqualTo("/orders"))
        .withQueryParam("userId", equalTo("42"))
        .willReturn(okJson("[]")));
 
    webTestClient.get()
        .uri("/api/dashboard/42")
        .header("Authorization", "Bearer eyJhbGciOiJSUzI1NiJ9.test.token")
        .exchange()
        .expectStatus().isOk();
 
    // Verify downstream received the Authorization header via Token Relay
    verify(getRequestedFor(urlEqualTo("/users/42"))
        .withHeader("Authorization", containing("Bearer")));
 
    // Verify correlation ID was forwarded
    verify(getRequestedFor(urlEqualTo("/users/42"))
        .withHeader("X-Correlation-ID", matching(".+")));
}

Simulating Slow Downstreams (Latency Testing)

@Test
void shouldTimeoutWhenDownstreamIsSlow() {
    stubFor(get(urlEqualTo("/users/42"))
        .willReturn(okJson("""{"id": 42}""")
            .withFixedDelay(6000)  // 6 seconds — beyond the 5s timeout
        ));
 
    webTestClient.get()
        .uri("/api/dashboard/42")
        .exchange()
        .expectStatus().isEqualTo(504);  // Gateway Timeout
}

TEST-03: Spring Cloud Contract — Consumer-Driven

Concept

Consumer-driven contract testing (see Consumer-Driven-Contract-Testing) works as follows:

  1. The BFF team (consumer) writes contracts describing what it expects from downstream APIs
  2. Contracts are stored in the BFF's source repo under src/test/resources/contracts/
  3. Spring Cloud Contract generates:
    • WireMock stubs from contracts (used in BFF integration tests)
    • Provider verification tests that the downstream team runs against their service
  4. If the downstream changes its API in a breaking way, the provider verification tests fail

This inverts the traditional "provider publishes API docs" workflow — the consumer specifies what it needs.

BFF as Consumer — Dependencies

// BFF build.gradle
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
 
// On the downstream provider service:
// testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
// testImplementation 'org.springframework.cloud:spring-cloud-contract-stub-runner'

Writing Contracts (Groovy DSL)

Contracts live in src/test/resources/contracts/<service-name>/:

src/test/resources/contracts/user-service/get-user-by-id.groovy:

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    description "BFF gets user by ID — returns 200 with user object"
 
    request {
        method GET()
        url "/users/42"
        headers {
            header("Authorization", matching("Bearer .+"))
            header("Accept", "application/json")
        }
    }
 
    response {
        status OK()
        headers {
            contentType applicationJson()
        }
        body([
            id   : 42,
            name : "Jane Doe",
            email: "jane@example.com"
        ])
        bodyMatchers {
            jsonPath('$.id', byEquality())
            jsonPath('$.name', byRegex('[A-Za-z ]+'))
            jsonPath('$.email', byRegex('.+@.+\\..+'))
        }
    }
}

src/test/resources/contracts/user-service/get-user-not-found.groovy:

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    description "BFF requests non-existent user — provider returns 404"
 
    request {
        method GET()
        url "/users/99999"
        headers {
            header("Authorization", matching("Bearer .+"))
        }
    }
 
    response {
        status NOT_FOUND()
        headers {
            contentType applicationJson()
        }
        body([
            error: "User not found",
            id   : 99999
        ])
    }
}

src/test/resources/contracts/order-service/get-orders-for-user.groovy:

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    description "BFF gets orders for a user"
 
    request {
        method GET()
        urlPath("/orders") {
            queryParameters {
                parameter("userId", "42")
            }
        }
        headers {
            header("Authorization", matching("Bearer .+"))
        }
    }
 
    response {
        status OK()
        headers {
            contentType applicationJson()
        }
        body([
            [
                id    : "ord-1",
                total : 99.99,
                status: "DELIVERED"
            ]
        ])
        bodyMatchers {
            jsonPath('$[0].id', byRegex('[a-z0-9-]+'))
            jsonPath('$[0].total', byRegex('[0-9]+\\.[0-9]+'))
            jsonPath('$[0].status', byRegex('DELIVERED|PROCESSING|CANCELLED'))
        }
    }
}

Using Contract-Generated Stubs in BFF Tests

Spring Cloud Contract generates WireMock stub JARs that the BFF can load via @AutoConfigureStubRunner:

package com.example.bff.contract;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
 
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@ActiveProfiles("test")
@AutoConfigureStubRunner(
    // Pull stubs from local Maven repo (after downstream builds and publishes stub JAR)
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = {
        "com.example:user-service:+:stubs:8091",
        "com.example:order-service:+:stubs:8092"
    }
)
class DashboardContractTest {
 
    @Autowired
    private WebTestClient webTestClient;
 
    @Test
    void shouldAggregateUsingContractStubs() {
        // The stub runner starts WireMock on 8091/8092 with the contract-generated stubs.
        // application-test.yml must point user-service to localhost:8091,
        // order-service to localhost:8092.
 
        webTestClient.get()
            .uri("/api/dashboard/42")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.user.name").isEqualTo("Jane Doe")
            .jsonPath("$.orders[0].id").isEqualTo("ord-1");
    }
}

Provider Verification (Downstream Side)

The downstream user-service adds this to its test suite — Spring Cloud Contract auto-generates the test class from the consumer's contract:

// Generated by Spring Cloud Contract — do not edit
// user-service/src/test/java/com/example/user/contract/UserContractVerificationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserContractVerificationTest extends ContractVerifierBase {
 
    // Spring Cloud Contract generates test methods like:
    // void validate_get_user_by_id() — sends GET /users/42 and verifies the response
    // void validate_get_user_not_found() — sends GET /users/99999 and verifies 404
}

The base class ContractVerifierBase sets up RestAssured (or WebTestClient for reactive providers) against the running application.

Contract Workflow in CI

BFF Repo                           User Service Repo
───────────────────────────────    ─────────────────────────────────
1. BFF writes contracts            3. Stubs published to artifact
   in src/test/resources/             registry after user-service CI
   contracts/user-service/
                                   4. Provider verification tests run
2. BFF CI generates stubs,            (contract sent from BFF repo
   runs BFF integration tests         or artifact registry)
   using those stubs
                                   5. If verification fails → CI blocks
                                      user-service deploy

Summary Table

AreaTool / ApproachSpring Boot 3 Note
Distributed tracingMicrometer Tracing + OTelReplaces Spring Cloud Sleuth (Boot 2)
Trace exportZipkin or Jaeger via OTLPBoth supported via OTel exporters
Trace propagationW3C Trace Context (traceparent)Auto-propagated by SCG + Micrometer
MetricsMicrometer + Prometheusmicrometer-registry-prometheus
Metrics dashboardGrafanaQuery via PromQL
Structured loggingLogback + logstash-logback-encoderJSON output with MDC fields
WebFlux MDCMdcContextLifter + Hooks.onEachOperatorThreadLocal MDC does not work in WebFlux
Unit testingJUnit 5 + Mockito + StepVerifierreactor-test for reactive assertions
Integration testingWireMock (wiremock-spring-boot) + @WireMockTestNot WireMockRule (JUnit 4)
Contract testingSpring Cloud ContractConsumer (BFF) writes contracts