Testing Resilience

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Resilience code is the code that runs when everything else goes wrong, which makes it the code that is least often exercised by a happy-path test suite and the code whose bugs are found in production, at night. A circuit breaker that never opens because the aspect was not applied, a retry that never retries because a fallback swallowed the exception, a timeout that is longer than the caller’s: each of them passes a test that only checks the successful response. This page shows how to test the failure paths, from a plain JUnit test of a breaker’s state machine to fault injection against a real HTTP client, with the Bookshelf PricingClient as the running example. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026: every test on this page was compiled and run on Boot 4.1.1 with JDK 21, except the Toxiproxy test, which needs Docker (see Toxiproxy with Testcontainers). General Spring Boot testing is covered by Unit and integration testing; this page adds only what is specific to resilience.

Testing token-bucket rate limits (a controllable time meter, distributed backends with Testcontainers and the 429 contract) is covered in Bucket4j: Testing Rate Limits.

The test topology

Four layers of test, each cheaper and more deterministic than the next, together cover a resilience configuration. Use the lowest layer that can show the behaviour you care about.

flowchart LR subgraph L1[Layer 1: plain JUnit, no Spring] U[Test] --> R[CircuitBreakerRegistry
+ MutableClock] end subgraph L2[Layer 2: SpringBootTest, profile test] T[Test] -->|calls| PC[PricingClient
AOP proxy] PC --> RE[Retry aspect] RE --> CB[CircuitBreaker aspect] CB -->|RestClient| WM[WireMock
dynamic port] T -.->|asserts| ACT[Actuator endpoints
and MeterRegistry] T -.->|asserts| EV[Event publishers] end subgraph L3[Layer 3: network faults, Docker] T3[Test] --> PC3[PricingClient] PC3 --> TP[Toxiproxy container] TP --> WM3[WireMock on the host] end subgraph L4[Layer 4: reactive, no server] S[StepVerifier] --> OP[CircuitBreakerOperator
RetryOperator] end

Layer 1 needs no Spring context and runs in milliseconds. It is the right place to check your configuration of the state machine: thresholds, wait durations, which exceptions count. Layer 2 starts the application context and proves that the configuration, the aspects and the HTTP client work together; WireMock plays the pricing service and can misbehave in ways a mock of RestClient cannot, because the failure happens inside the real HTTP stack. Layer 3 adds failures that only a network can produce and is the slowest. Layer 4 tests the reactive operators with StepVerifier.

The sample setup

The tests below use these test-scoped dependencies on top of the Resilience4j and Actuator setup from Getting started. Boot 4 renamed the Testcontainers artifacts and the Boot 3 coordinates are shown in the second column.

Purpose Boot 4.1.x (Testcontainers 2.0.5) Boot 3.5.x (Testcontainers 1.21.4)

Test starter

org.springframework.boot:spring-boot-starter-test

same

HTTP fault injection

org.wiremock:wiremock-standalone:3.13.2

same

Toxiproxy container

org.testcontainers:testcontainers-toxiproxy

org.testcontainers:toxiproxy

JUnit 5 integration

org.testcontainers:testcontainers-junit-jupiter

org.testcontainers:junit-jupiter

StepVerifier

io.projectreactor:reactor-test (version managed by Boot)

same

wiremock-standalone bundles its own shaded Jetty, which avoids version clashes with the Jetty that Spring Boot may manage. Testcontainers, JUnit and the container lifecycle are explained in Testcontainers fundamentals and Spring Boot integration tests with Testcontainers.

Layer 1: unit tests with a registry

A CircuitBreakerRegistry built in the test gives you a real state machine without Spring. Three things make such tests fast and deterministic: the manual transitions, reset() and getMetrics().

Manual transitions

transitionToOpenState(), transitionToHalfOpenState(), transitionToClosedState(), transitionToForcedOpenState(), transitionToDisabledState() and transitionToMetricsOnlyState() put the breaker into a state without any traffic. Use them to check what your code does while the breaker is open, without first producing the failures that open it. reset() returns the breaker to CLOSED and clears its metrics, which makes a shared breaker reusable between tests.

package com.example.bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreaker.State;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class CircuitBreakerRegistryTest {

    private CircuitBreaker breaker;

    @BeforeEach
    void setUp() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(4)
                .minimumNumberOfCalls(4)
                .failureRateThreshold(50)
                .waitDurationInOpenState(Duration.ofSeconds(30))
                .permittedNumberOfCallsInHalfOpenState(1)
                .build();
        breaker = CircuitBreakerRegistry.of(config).circuitBreaker("pricing");
    }

    @Test
    void forcedTransitionsNeedNoTraffic() {
        breaker.transitionToOpenState();
        assertThat(breaker.getState()).isEqualTo(State.OPEN);
        assertThatThrownBy(() -> breaker.executeSupplier(() -> "never runs"))
                .isInstanceOf(CallNotPermittedException.class);

        breaker.transitionToForcedOpenState();
        assertThat(breaker.getState()).isEqualTo(State.FORCED_OPEN);

        breaker.reset();
        assertThat(breaker.getState()).isEqualTo(State.CLOSED);
        assertThat(breaker.getMetrics().getNumberOfBufferedCalls()).isZero();
    }

    @Test
    void failuresOpenTheBreakerAndMetricsSayWhy() {
        for (int i = 0; i < 4; i++) {
            assertThatThrownBy(() -> breaker.executeSupplier(() -> {
                throw new IllegalStateException("boom");
            })).isInstanceOf(IllegalStateException.class);
        }
        assertThat(breaker.getState()).isEqualTo(State.OPEN);
        assertThat(breaker.getMetrics().getFailureRate()).isEqualTo(100.0f);
        assertThat(breaker.getMetrics().getNumberOfFailedCalls()).isEqualTo(4);
    }
}

getMetrics() returns a live view with getFailureRate(), getSlowCallRate(), getNumberOfBufferedCalls(), getNumberOfFailedCalls(), getNumberOfSlowCalls() and getNumberOfNotPermittedCalls(). Asserting on them says why a breaker opened, not only that it did. The properties and the state machine are in CircuitBreaker.

Events as an ordered log

Every state change is published as an event. Registering a consumer before the act and asserting on the collected list is the simplest way to test a sequence, which a snapshot of the final state cannot show:

@Test
void eventsAreAnOrderedLog() {
    List<String> seen = new ArrayList<>();
    breaker.getEventPublisher().onStateTransition(e -> seen.add(e.getStateTransition().name()));
    breaker.transitionToOpenState();
    breaker.transitionToHalfOpenState();
    breaker.transitionToClosedState();
    assertThat(seen).containsExactly("CLOSED_TO_OPEN", "OPEN_TO_HALF_OPEN", "HALF_OPEN_TO_CLOSED");
}

Deterministic time with a custom Clock

The open state waits waitDurationInOpenState before the next call may probe the backend. A test that sleeps for that duration is slow and flaky; a test that sets the wait to a few milliseconds changes the configuration under test. Instead, give the breaker a java.time.Clock that the test advances by hand. CircuitBreakerConfig.Builder.clock(Clock) replaces the clock used for the wait in the open state. Slow-call timing does not use that clock by default: it uses System.nanoTime(), which is why the same builder has currentTimestampFunction(Function<Clock, Long>, TimeUnit). Pass both and the whole state machine runs on your clock.

static final class MutableClock extends Clock {
    private Instant now = Instant.parse("2026-10-04T10:00:00Z");

    void advance(Duration d) { now = now.plus(d); }

    @Override public ZoneId getZone() { return ZoneId.of("UTC"); }
    @Override public Clock withZone(ZoneId zone) { return this; }
    @Override public Instant instant() { return now; }
}

private final MutableClock clock = new MutableClock();

@BeforeEach
void setUp() {
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .slidingWindowSize(4).minimumNumberOfCalls(4)
            .failureRateThreshold(50)
            .slowCallDurationThreshold(Duration.ofMillis(300))
            .slowCallRateThreshold(100)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .permittedNumberOfCallsInHalfOpenState(1)
            .clock(clock)
            .currentTimestampFunction(c -> c.millis(), TimeUnit.MILLISECONDS)
            .build();
    breaker = CircuitBreakerRegistry.of(config).circuitBreaker("pricing");
}

@Test
void waitDurationElapsesWithTheFakeClock() {
    breaker.transitionToOpenState();
    assertThat(breaker.tryAcquirePermission()).isFalse();   // the clock stands still

    clock.advance(Duration.ofSeconds(31));
    assertThat(breaker.tryAcquirePermission()).isTrue();    // the HALF_OPEN probe
    assertThat(breaker.getState()).isEqualTo(State.HALF_OPEN);
    breaker.onSuccess(0, TimeUnit.MILLISECONDS);
    assertThat(breaker.getState()).isEqualTo(State.CLOSED);
}

@Test
void slowCallsAreCountedFromTheFakeClock() {
    for (int i = 0; i < 4; i++) {
        breaker.executeRunnable(() -> clock.advance(Duration.ofMillis(400)));
    }
    assertThat(breaker.getMetrics().getNumberOfSlowCalls()).isEqualTo(4);
    assertThat(breaker.getState()).isEqualTo(State.OPEN);
}

Source: CircuitBreakerConfig at v2.4.0 (clock, currentTimestampFunction)

The test advances time inside the protected call to simulate a slow backend, so four calls that return instantly take 400 milliseconds each on the breaker’s clock and trip the slow-call rate. Neither clock nor currentTimestampFunction has a Spring Boot property: in a Spring test, set them through a CircuitBreakerConfigCustomizer bean (see Spring Boot configuration). Two limits apply. A TIME_BASED window with the lock-free synchronisation strategy ignores the configured clock, so keep tests with a fake clock on COUNT_BASED windows or the default strategy. And only the circuit breaker has this hook: Retry and RateLimiter read the system time, so test their timing with short real durations or, for reactive code, virtual time (see Layer 4: reactive pipelines with StepVerifier).

A test profile with small windows

A production breaker with a window of twenty calls and a thirty-second wait is the right configuration and the wrong one to test. Keep the production values in application.yml and shrink them in application-test.yml, activated with @ActiveProfiles("test"). Because Spring Boot merges profile files over the base file, the test file lists only the differences:

# src/main/resources/application.yml (production values)
resilience4j:
  circuitbreaker:
    instances:
      pricing:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 20
        minimumNumberOfCalls: 10
        failureRateThreshold: 50
        slowCallDurationThreshold: 2s
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 3
  retry:
    instances:
      pricing:
        maxAttempts: 3
        waitDuration: 200ms
# src/test/resources/application-test.yml (only what differs)
spring:
  http:
    clients:
      connect-timeout: 300ms
      read-timeout: 500ms

resilience4j:
  circuitbreaker:
    instances:
      pricing:
        slidingWindowSize: 4
        minimumNumberOfCalls: 4
        slowCallDurationThreshold: 300ms
        waitDurationInOpenState: 1h
        permittedNumberOfCallsInHalfOpenState: 1
  retry:
    instances:
      pricing:
        waitDuration: 10ms

Source: Spring Boot: Profiles and the properties tables in Spring Boot configuration

Two choices in the test file are deliberate. waitDurationInOpenState: 1h means that a breaker that opens in a test stays open until the test says otherwise, so nothing depends on how fast the machine is. And the HTTP client’s timeouts are set under spring.http.clients (plural), the keys of Boot 4.1; the singular spring.http.client.* keys that older posts show do not configure the client in 4.1.1 (see TimeLimiter).

Layer 2: @SpringBootTest checks that the aspects apply

The most valuable single test in a resilience-enabled service checks that the annotations do something. If spring-boot-starter-aspectj (Boot 3: spring-boot-starter-aop) is missing, the registries and the Actuator endpoints still exist but @CircuitBreaker and @Retry are silent no-ops, and nothing fails at startup (verified on 4.1.1). The same happens when the annotation is on a private method, or only on an interface method of an ordinary bean. A test that makes the backend fail and asserts the observable consequence catches all of them. The pricing client is:

@Component
public class PricingClient {

    private final RestClient http;

    public PricingClient(RestClient.Builder builder,
                         @Value("${bookshelf.pricing.url}") String baseUrl) {
        this.http = builder.baseUrl(baseUrl).build();
    }

    @Retry(name = "pricing", fallbackMethod = "staleBookPrice")
    @CircuitBreaker(name = "pricing")
    public Money price(String isbn) {
        return http.get().uri("/prices/{isbn}", isbn).retrieve().body(Money.class);
    }

    Money staleBookPrice(String isbn, Throwable cause) {
        return new Money(isbn, 0, true);
    }
}

The fallback sits on @Retry, the outermost aspect, on purpose. A fallback on @CircuitBreaker runs inside the retry, turns every failure into a normal return, and Retry never retries: the first draft of this page’s test had the fallback on the breaker and saw exactly one attempt. The test below is what caught it. See Fallbacks and composition and Annotations and aspect order.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class PricingResilienceTest {

    @RegisterExtension
    static WireMockExtension pricing = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort()).build();

    @DynamicPropertySource
    static void urls(DynamicPropertyRegistry registry) {
        registry.add("bookshelf.pricing.url", pricing::baseUrl);
        registry.add("bookshelf.recommendations.url", pricing::baseUrl);
    }

    @Autowired PricingClient client;
    @Autowired CircuitBreakerRegistry breakers;
    @Autowired RetryRegistry retries;
    @Autowired MeterRegistry meters;
    @LocalServerPort int port;

    @BeforeEach
    void clean() {
        pricing.resetAll();
        breakers.circuitBreaker("pricing").reset();
    }

    @Test
    void theAspectsAreApplied() {
        assertThat(AopUtils.isAopProxy(client)).isTrue();
        pricing.stubFor(get(urlPathEqualTo("/prices/978-0")).willReturn(aResponse().withStatus(503)));
        assertThat(client.price("978-0").stale()).isTrue();
    }

    @Test
    void retryIsOutsideTheBreaker() {
        List<String> log = Collections.synchronizedList(new ArrayList<>());
        retries.retry("pricing").getEventPublisher()
                .onRetry(e -> log.add("retry#" + e.getNumberOfRetryAttempts()));
        breakers.circuitBreaker("pricing").getEventPublisher()
                .onError(e -> log.add("breaker-error"));
        pricing.stubFor(get(urlPathEqualTo("/prices/978-1")).willReturn(aResponse().withStatus(500)));

        client.price("978-1");

        // Retry outermost: the breaker sees every attempt and records each failure.
        assertThat(log).containsExactly(
                "breaker-error", "retry#1", "breaker-error", "retry#2", "breaker-error");
        pricing.verify(3, getRequestedFor(urlPathEqualTo("/prices/978-1")));
    }

    @Test
    void breakerOpensAfterFourFailuresAndStopsCallingTheBackend() {
        pricing.stubFor(get(urlPathEqualTo("/prices/978-5")).willReturn(aResponse().withStatus(500)));
        client.price("978-5");          // 3 attempts, 3 failures recorded
        client.price("978-5");          // the 4th failure opens the breaker, the retries are rejected
        assertThat(breakers.circuitBreaker("pricing").getState())
                .isEqualTo(CircuitBreaker.State.OPEN);
        int before = pricing.getAllServeEvents().size();
        assertThat(client.price("978-5").stale()).isTrue();
        assertThat(pricing.getAllServeEvents()).hasSize(before);   // no request reached the backend
    }
}

The second test is the one that verifies the aspect order through events. If the order were reversed (breaker outside retry), the breaker would see a single recorded failure for the whole call, not three, and the log would be shorter. The event lists in the two tests are sequences that the final state could not show. The third test proves the point of the breaker: once it is open, the backend receives no more requests.

sequenceDiagram participant T as Test participant R as Retry aspect (outer) participant C as CircuitBreaker aspect (inner) participant W as WireMock T->>R: price("978-1") R->>C: attempt 1 C->>W: GET /prices/978-1 W-->>C: 500 C-->>R: failure (event: breaker-error) Note over R: event: retry#1 R->>C: attempt 2 C->>W: GET W-->>C: 500 C-->>R: failure (event: breaker-error) Note over R: event: retry#2 R->>C: attempt 3 C->>W: GET W-->>C: 500 C-->>R: failure (event: breaker-error) R-->>T: fallback Money(stale = true)
Figure 1. What the retryIsOutsideTheBreaker test observes

The @LocalServerPort import used here, on Boot 4, is org.springframework.boot.test.web.server.LocalServerPort. The application context is cached and shared by all tests in the class, so every test starts with reset() and resetAll().

WireMock fault injection

WireMock injects failure at the HTTP level. Each scenario below is a separate test in the same class and, together, they check every way the pricing call can fail against the one pricing configuration:

Failure Stub What the test asserts

Slow backend

aResponse().withStatus(200).withFixedDelay(2000) with the 500 ms read timeout of the test profile

The call fails with a timeout and the fallback value is returned.

Server error

aResponse().withStatus(500) or 503

Three attempts reach WireMock and the breaker records three failures.

Rate limited

aResponse().withStatus(429).withHeader("Retry-After", "1")

Three attempts, 10 ms apart: Retry does not read Retry-After unless an intervalBiFunction is configured.

Connection dropped

aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)

The breaker’s failed-call count is 3 and the fallback is returned.

@Test
void slowResponseIsATimeoutAndFallsBack() {
    pricing.stubFor(get(urlPathEqualTo("/prices/978-2"))
            .willReturn(aResponse().withStatus(200).withFixedDelay(2000)));
    assertThat(client.price("978-2").stale()).isTrue();
}

@Test
void connectionResetIsAFailure() {
    pricing.stubFor(get(urlPathEqualTo("/prices/978-3"))
            .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
    assertThat(client.price("978-3").stale()).isTrue();
    assertThat(breakers.circuitBreaker("pricing").getMetrics().getNumberOfFailedCalls())
            .isEqualTo(3);
}

@Test
void tooManyRequestsWithRetryAfterIsAFailure() {
    pricing.stubFor(get(urlPathEqualTo("/prices/978-4"))
            .willReturn(aResponse().withStatus(429).withHeader("Retry-After", "1")));
    assertThat(client.price("978-4").stale()).isTrue();
    // Retry does not read Retry-After on its own: three attempts, 10 ms apart.
    pricing.verify(3, getRequestedFor(urlPathEqualTo("/prices/978-4")));
}

The delay test passes only because the test profile sets a read timeout. The stub returns a valid 200 after two seconds, so without a client timeout the call would simply succeed late and the test would fail on the assertion, not hang: it would show that the timeout the production configuration relies on was never set. Retry-After handling with intervalBiFunction is in Retry; the 429 returned by your own service when a rate limiter rejects is in RateLimiter.

Toxiproxy with Testcontainers

WireMock simulates a misbehaving server. Toxiproxy sits between your client and the server as a TCP proxy and misbehaves at the connection level: it adds latency, resets connections, cuts the connection after a number of bytes, or simply stops forwarding. It is the closest a test gets to a bad network, and the faults can be switched on and off between two calls of the same test. Run it with Testcontainers, which needs a Docker-compatible runtime; the container and JUnit integration are in Spring Boot integration tests with Testcontainers.

@SpringBootTest
@ActiveProfiles("test")
@org.testcontainers.junit.jupiter.Testcontainers(disabledWithoutDocker = true)
class PricingToxiproxyTest {

    private static final int PROXY_PORT = 8666;

    @RegisterExtension
    static WireMockExtension pricing = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort()).build();

    @Container
    static ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.12.0")
            .withAccessToHost(true);

    static Proxy proxy;

    @BeforeAll
    static void createProxy() throws IOException {
        // The container reaches the WireMock server running in the test JVM through the host alias.
        Testcontainers.exposeHostPorts(pricing.getPort());
        ToxiproxyClient control = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort());
        proxy = control.createProxy("pricing", "0.0.0.0:" + PROXY_PORT,
                "host.testcontainers.internal:" + pricing.getPort());
    }

    @DynamicPropertySource
    static void urls(DynamicPropertyRegistry registry) {
        registry.add("bookshelf.pricing.url",
                () -> "http://" + toxiproxy.getHost() + ":" + toxiproxy.getMappedPort(PROXY_PORT));
        registry.add("bookshelf.recommendations.url", pricing::baseUrl);
    }

    @Autowired PricingClient client;
    @Autowired CircuitBreakerRegistry breakers;

    @BeforeEach
    void healthyAgain() throws IOException {
        proxy.toxics().getAll().forEach(t -> {
            try { t.remove(); } catch (IOException e) { throw new IllegalStateException(e); }
        });
        proxy.enable();
        breakers.circuitBreaker("pricing").reset();
        pricing.stubFor(get(urlPathEqualTo("/prices/978-0")).willReturn(aResponse()
                .withHeader("Content-Type", "application/json")
                .withBody("{\"isbn\":\"978-0\",\"cents\":1999,\"stale\":false}")));
    }

    @Test
    void latencyBeyondTheReadTimeoutFallsBack() throws IOException {
        assertThat(client.price("978-0").stale()).isFalse();
        proxy.toxics().latency("slow", ToxicDirection.DOWNSTREAM, 2_000);
        assertThat(client.price("978-0").stale()).isTrue();
    }

    @Test
    void droppedConnectionsFallBackAndRecover() throws IOException {
        proxy.disable();
        assertThat(client.price("978-0").stale()).isTrue();
        proxy.enable();
        breakers.circuitBreaker("pricing").reset();
        assertThat(client.price("978-0").stale()).isFalse();
    }
}

This test was compiled against Testcontainers 2.0.5 and toxiproxy-java 2.1.11 but not executed: Docker was not running on the machine where this page was verified, and the test is annotated with disabledWithoutDocker = true, so it is skipped when no Docker is found (two tests reported as skipped). The image tag 2.12.0 was confirmed to exist in the ghcr.io/shopify/toxiproxy registry, but the image was not pulled. Treat the Toxiproxy code as compiled, API-checked and untested, and run it once in your own environment before you rely on it. Two container details matter. ToxiproxyContainer already exposes the control port 8474 and the proxy ports 8666 to 8697, so do not call withExposedPorts(…​) on it: that call replaces the exposed ports, drops 8474 and breaks the container’s wait strategy. And withAccessToHost(true) makes Testcontainers start its host-port forwarder together with the container, so the host.testcontainers.internal alias exists inside Toxiproxy and the later Testcontainers.exposeHostPorts(…​) call for the WireMock port takes effect; without it the alias is only added if the forwarder was already running when the container was created. The shape is the standard one: create the proxy in @BeforeAll, point the client at the proxy’s mapped port, add or remove toxics (latency, resetPeer, timeout, limitData, bandwidth, slicer, slowClose) in the test, and remove them in @BeforeEach so tests do not leak faults into each other. Boot 4 uses Testcontainers 2.x, where ToxiproxyContainer lives in org.testcontainers.toxiproxy and has no getProxy helper: the control client comes from the separate toxiproxy-java library, which the module brings in. On Boot 3 (Testcontainers 1.x) the container is org.testcontainers.containers.ToxiproxyContainer.

Asserting on Actuator endpoints and meters

The Actuator endpoints and the Micrometer meters are the contract that dashboards and runbooks depend on, so they deserve a test. The same context serves both: the endpoint over HTTP (RANDOM_PORT), the meter through the autowired MeterRegistry.

@Test
void actuatorAndMetersAgreeWithTheRegistry() {
    breakers.circuitBreaker("pricing").transitionToForcedOpenState();

    RestClient http = RestClient.create("http://localhost:" + port);
    String json = http.get().uri("/actuator/circuitbreakers").retrieve().body(String.class);
    assertThat(json).contains("\"pricing\"").contains("FORCED_OPEN");

    double open = meters.get("resilience4j.circuitbreaker.state")
            .tag("name", "pricing").tag("state", "forced_open").gauge().value();
    assertThat(open).isEqualTo(1.0);
}

The endpoint answered with this body in the test (formatted here):

{
  "circuitBreakers": {
    "pricing": {
      "bufferedCalls": 0, "failedCalls": 0, "failureRate": "-1.0%",
      "failureRateThreshold": "50.0%", "notPermittedCalls": 0,
      "slowCallRate": "-1.0%", "slowCallRateThreshold": "100.0%",
      "slowCalls": 0, "slowFailedCalls": 0, "state": "FORCED_OPEN"
    }
  }
}

Assert on tags and values, not on the full JSON: the state transition text in the events endpoints differs between Boot lines (State transition from CLOSED to OPEN on Boot 4.1.1, CLOSED_TO_OPEN on Boot 3.5.16), and a test that compares a whole body breaks on an upgrade. If the endpoints are secured in your application, the test needs the same credentials as a caller; use spring-security-test helpers or a test user and keep the values in the test configuration, never in the page or the repository.

Layer 4: reactive pipelines with StepVerifier

CircuitBreakerOperator and RetryOperator from resilience4j-reactor are applied with transformDeferred. StepVerifier (from reactor-test) subscribes to the pipeline and asserts on the signals. StepVerifier.withVirtualTime replaces the scheduler’s clock, so a test of a ten-second backoff takes milliseconds:

class RecommendationsStepVerifierTest {

    @Test
    void openBreakerRejectsTheSubscription() {
        CircuitBreaker breaker = CircuitBreakerRegistry.ofDefaults().circuitBreaker("recommendations");
        breaker.transitionToOpenState();
        AtomicInteger subscribed = new AtomicInteger();

        Mono<String> call = Mono.fromSupplier(() -> { subscribed.incrementAndGet(); return "x"; })
                .transformDeferred(CircuitBreakerOperator.of(breaker));

        StepVerifier.create(call).expectError(CallNotPermittedException.class).verify();
        assertThat(subscribed).hasValue(0);
    }

    @Test
    void retryBackoffRunsOnVirtualTime() {
        Retry retry = Retry.of("recommendations", RetryConfig.custom()
                .maxAttempts(3).waitDuration(Duration.ofSeconds(10)).build());
        AtomicInteger calls = new AtomicInteger();

        StepVerifier.withVirtualTime(() -> Mono.defer(() -> {
                    if (calls.incrementAndGet() < 3) {
                        return Mono.<String>error(new IllegalStateException("flaky"));
                    }
                    return Mono.just("ok");
                }).transformDeferred(RetryOperator.of(retry)))
                .expectSubscription()
                .expectNoEvent(Duration.ofSeconds(9))
                .thenAwait(Duration.ofSeconds(21))
                .expectNext("ok")
                .verifyComplete();
        assertThat(calls).hasValue(3);
    }
}

Two details matter. The first test shows that an open breaker rejects the subscription: the source is never subscribed, so subscribed stays at zero. The second builds the source inside Mono.defer, because a retry resubscribes and must evaluate the supplier again; a Mono.error(…​) built once is the same failed Mono every time. Without resilience4j-reactor on the classpath, @CircuitBreaker on a method that returns a Mono is a silent no-op that records a success, so a test like the first one is the only thing that notices (see Reactive, Kotlin and RxJava).

For WebClient pipelines, the same WireMock stubs from layer 2 work: RecommendationsClient in the example project wires a WebClient, a timeout and the operator, and the test points it at WireMock exactly as the pricing client does.

Chaos and game days

Automated tests prove that the mechanisms you configured work. They do not prove that the configuration is right for your real dependencies. Complement them with experiments in a pre-production environment, in this order of increasing risk:

  • Replay the incident. Take the latency and error profile of a past outage and replay it with a Toxiproxy toxic or a WireMock delay while a load generator runs. Check that the breaker opens in the time you predicted and that the fallback carries the load (CircuitBreaker shows such a replay for the pricing service).

  • Break one dependency at a time and look at the others. A bulkhead is correct if the healthy pricing call keeps its latency while recommendations is down.

  • Use the kill switch. Force a breaker open through the Actuator write operation during a quiet period, and confirm that the service degrades as designed and that alerts fire. The procedure is in Production tuning and anti-patterns.

  • Inject faults inside the application. Tools such as Chaos Monkey for Spring Boot add latency and exceptions to Spring beans at runtime, which exercises the aspects without a proxy.

  • Run a game day. Agree on a steady-state metric (for instance, the success rate of GET /books/{isbn}), a hypothesis ("with recommendations down, the book page still renders"), a blast radius and an abort condition, as in the Principles of Chaos Engineering.

Pitfalls

Pitfall What happens and what to do

The fallback is on the innermost annotation

A @CircuitBreaker(fallbackMethod = …​) inside @Retry converts failures into normal returns, so Retry sees success and never retries (verified: one attempt instead of three). Put the fallback on the outermost annotation, and test the number of backend requests, not only the returned value.

The aspect is not applied and nothing says so

Without spring-boot-starter-aspectj (Boot 3: -aop), the annotations are no-ops and the application starts normally. Assert AopUtils.isAopProxy(bean) and one observable consequence in a @SpringBootTest.

A breaker’s state leaks between tests

The application context is cached, and so are the registries. Call reset() in @BeforeEach, and WireMockExtension.resetAll(). A test that passes alone and fails in the suite is usually this.

Sleeping to wait for the open state

Slow and flaky. Use a custom Clock (layer 1), a one-hour wait in the test profile plus transitionToHalfOpenState(), or StepVerifier.withVirtualTime.

Clock configured but slow calls are not counted

Slow-call timing uses currentTimestampFunction, whose default is System.nanoTime(). Configure both clock and currentTimestampFunction.

clock ignored on a time-based window

A TIME_BASED window with the lock-free strategy ignores the configured clock. Use a COUNT_BASED window in such tests.

Window and minimumNumberOfCalls too large for the test

A breaker with minimumNumberOfCalls: 100 never opens in a ten-call test, and the failure looks like a bug in the breaker. Shrink both in application-test.yml.

Delay stub without a client timeout

A WireMock delay only tests the timeout if the HTTP client has one. Set spring.http.clients.read-timeout (plural) in the test profile; assert the fallback, not just the delay.

Retry counts hide inside breaker metrics

With Retry outside the breaker, one call records up to maxAttempts failures. Compute expected numbers from both, as the tests above do.

A 429 with Retry-After retried in milliseconds

By default Retry ignores the header and uses its own waitDuration. Test the number of requests, then configure intervalBiFunction (see the Retry page).

FORCED_OPEN or DISABLED expected in the event stream

Entering those states publishes a state-transition event (verified: CLOSED_TO_FORCED_OPEN, FORCED_OPEN_TO_DISABLED, DISABLED_TO_METRICS_ONLY), but dedicated FORCED_OPEN/DISABLED event types are never published. Assert on onStateTransition.

Asserting on whole JSON or on transition text

The text of a state transition differs between Boot 4.1.1 and Boot 3.5.16. Assert on the fields you care about.

Toxiproxy tests in a build without Docker

disabledWithoutDocker = true skips them silently. Make sure at least one pipeline runs them, and count them in the report so that a skip does not look like a pass.

Real secrets in secured-endpoint tests

Take credentials from test configuration or environment placeholders, not from literals in source.

What changed recently

  • Testcontainers 2.0 (Boot 4) renamed the modules to testcontainers-toxiproxy and testcontainers-junit-jupiter, moved ToxiproxyContainer to org.testcontainers.toxiproxy and dropped the old getProxy helper in the new class; both the old and new container classes are present in the testcontainers-toxiproxy 2.0.5 jar. Boot 3.5.x uses Testcontainers 1.21.4 with the old artifact names.

  • Boot 4.1 configures the HTTP client timeouts with spring.http.clients.connect-timeout and read-timeout, and @LocalServerPort is imported from org.springframework.boot.test.web.server in the tests above. Boot 4 also needs spring-boot-starter-restclient and spring-boot-starter-webclient for the RestClient.Builder and WebClient.Builder beans used in these tests.

  • Resilience4j 2.4.0 adds resilience4j-spring-boot4. The test APIs used here (transitionTo*, reset(), getMetrics(), the event publishers, CircuitBreakerConfig.Builder.clock) are unchanged from 2.3.x; the release notes for 2.4.0 are only on the GitHub release page.

  • The readme.io pages do not cover testing Spring Boot applications at all, and their code examples use the Vavr Try API, which is optional since 2.0. The Clock and currentTimestampFunction settings are not in its property table. Follow the v2.4.0 source. All discrepancies are collected in Migration and what’s changed.