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.
+ 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 |
|
same |
HTTP fault injection |
|
same |
Toxiproxy container |
|
|
JUnit 5 integration |
|
|
|
|
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);
}
}
Source: Resilience4j: CircuitBreaker and
CircuitBreaker at v2.4.0
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");
}
Source: Resilience4j: CircuitBreaker
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);
}
}
Source: Resilience4j: Spring Boot 2 & 3
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
}
}
Source: WireMock: JUnit 5 Jupiter and Spring Framework: Context configuration with dynamic property sources
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.
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 |
|
The call fails with a timeout and the fallback value is returned. |
Server error |
|
Three attempts reach WireMock and the breaker records three failures. |
Rate limited |
|
Three attempts, 10 ms apart: Retry does not read |
Connection dropped |
|
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")));
}
Source: WireMock: Simulating faults and WireMock: Stubbing
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();
}
}
Source: Testcontainers: Toxiproxy module and Toxiproxy
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"
}
}
}
Source: Spring Boot: Endpoints and Spring Boot: Metrics; meter names are in Observability
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);
}
}
Source: Reactor: Testing and
CircuitBreakerOperator at v2.4.0
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
pricingcall keeps its latency whilerecommendationsis 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 ("withrecommendationsdown, 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 |
The aspect is not applied and nothing says so |
Without |
A breaker’s state leaks between tests |
The application context is cached, and so are the registries. Call |
Sleeping to wait for the open state |
Slow and flaky. Use a custom |
|
Slow-call timing uses |
|
A |
Window and |
A breaker with |
Delay stub without a client timeout |
A WireMock delay only tests the timeout if the HTTP client has one. Set |
Retry counts hide inside breaker metrics |
With Retry outside the breaker, one call records up to |
A 429 with |
By default Retry ignores the header and uses its own |
|
Entering those states publishes a state-transition event (verified: |
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 |
|
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-toxiproxyandtestcontainers-junit-jupiter, movedToxiproxyContainertoorg.testcontainers.toxiproxyand dropped the oldgetProxyhelper in the new class; both the old and new container classes are present in thetestcontainers-toxiproxy2.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-timeoutandread-timeout, and@LocalServerPortis imported fromorg.springframework.boot.test.web.serverin the tests above. Boot 4 also needsspring-boot-starter-restclientandspring-boot-starter-webclientfor theRestClient.BuilderandWebClient.Builderbeans 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
TryAPI, which is optional since 2.0. TheClockandcurrentTimestampFunctionsettings are not in its property table. Follow the v2.4.0 source. All discrepancies are collected in Migration and what’s changed.