Testing Resilience4j in Spring Boot: Circuit Breakers, Retry, and Bulkheads

Testing Resilience4j in Spring Boot: Circuit Breakers, Retry, and Bulkheads

Resilience4j is the de-facto resilience library for Java applications, but most teams add it to production code and never actually verify it works. The circuit breaker is configured, the retry policy exists, and everyone feels safe — until a downstream service browns out and the circuit breaker stays closed because no one ever tested the threshold configuration.

This post covers how to test every major Resilience4j module in Spring Boot: circuit breakers, retry, bulkhead, and rate limiter. Real tests with assertions, not just "it compiled."

Project Setup

Add the dependencies to pom.xml:

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.1.0</version>
</dependency>
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-micrometer</artifactId>
    <version>2.1.0</version>
</dependency>

<!-- Test dependencies -->
<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.35.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <scope>test</scope>
</dependency>

Base application.yml configuration to test against:

resilience4j:
  circuitbreaker:
    instances:
      paymentService:
        registerHealthIndicator: true
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
        waitDurationInOpenState: 5s
        failureRateThreshold: 50
        eventConsumerBufferSize: 10
  retry:
    instances:
      paymentService:
        maxAttempts: 3
        waitDuration: 100ms
        retryExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
  bulkhead:
    instances:
      paymentService:
        maxConcurrentCalls: 5
        maxWaitDuration: 0ms
  ratelimiter:
    instances:
      paymentService:
        limitForPeriod: 10
        limitRefreshPeriod: 1s
        timeoutDuration: 0ms

Testing Circuit Breakers

The circuit breaker has three states: CLOSED (normal), OPEN (failing fast), HALF_OPEN (probing). Tests need to exercise all three transitions.

@SpringBootTest
@ExtendWith(MockitoExtension.class)
class CircuitBreakerTest {

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    @Autowired
    private PaymentService paymentService;

    @MockBean
    private PaymentClient paymentClient;

    private CircuitBreaker circuitBreaker;

    @BeforeEach
    void setUp() {
        circuitBreaker = circuitBreakerRegistry.circuitBreaker("paymentService");
        circuitBreaker.reset(); // Start from clean state
    }

    @Test
    void shouldTransitionToOpenAfterFailureThreshold() {
        // Arrange: configure client to always fail
        when(paymentClient.charge(any()))
            .thenThrow(new RuntimeException("Payment gateway down"));

        // Act: make enough calls to trigger the threshold
        // minimumNumberOfCalls=5, failureRateThreshold=50 → need 5 calls, 3+ failures
        for (int i = 0; i < 5; i++) {
            assertThrows(Exception.class,
                () -> paymentService.processPayment(new PaymentRequest("100")));
        }

        // Assert: circuit breaker should now be OPEN
        assertThat(circuitBreaker.getState())
            .isEqualTo(CircuitBreaker.State.OPEN);
    }

    @Test
    void shouldReturnFallbackWhenCircuitOpen() {
        // Force open state directly
        circuitBreaker.transitionToOpenState();

        // Act
        PaymentResult result = paymentService.processPayment(new PaymentRequest("100"));

        // Assert: fallback response returned, not an exception
        assertThat(result.isSuccess()).isFalse();
        assertThat(result.getErrorCode()).isEqualTo("CIRCUIT_OPEN");

        // Verify upstream was never called
        verify(paymentClient, never()).charge(any());
    }

    @Test
    void shouldTransitionToHalfOpenAfterWaitDuration() throws InterruptedException {
        circuitBreaker.transitionToOpenState();

        // Wait for the waitDurationInOpenState (5s in config — override for test)
        // In tests, use a test-specific config with shorter duration
        Thread.sleep(6000); // or use Awaitility

        assertThat(circuitBreaker.getState())
            .isEqualTo(CircuitBreaker.State.HALF_OPEN);
    }

    @Test
    void shouldTransitionToClosedAfterSuccessfulProbes() {
        circuitBreaker.transitionToHalfOpenState();

        // Configure success responses for probes
        when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));

        // permittedNumberOfCallsInHalfOpenState=3 — need 3 successful calls
        for (int i = 0; i < 3; i++) {
            paymentService.processPayment(new PaymentRequest("100"));
        }

        assertThat(circuitBreaker.getState())
            .isEqualTo(CircuitBreaker.State.CLOSED);
    }

    @Test
    void shouldTrackMetricsCorrectly() {
        when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));

        paymentService.processPayment(new PaymentRequest("50"));
        paymentService.processPayment(new PaymentRequest("50"));

        CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics();
        assertThat(metrics.getNumberOfSuccessfulCalls()).isEqualTo(2);
        assertThat(metrics.getNumberOfFailedCalls()).isEqualTo(0);
        assertThat(metrics.getFailureRate()).isEqualTo(-1.0f); // -1 = not enough calls yet
    }
}

Testing with WireMock for Integration Confidence

Unit tests with mocks verify the circuit breaker state machine. Integration tests with WireMock verify the HTTP client actually triggers the circuit breaker:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8089)
class CircuitBreakerIntegrationTest {

    @Autowired
    private PaymentService paymentService;

    @Autowired
    private CircuitBreakerRegistry registry;

    @Test
    void shouldOpenCircuitOnRepeated503Responses(WireMockRuntimeInfo wmRuntimeInfo) {
        // Simulate downstream returning 503
        stubFor(post(urlEqualTo("/charge"))
            .willReturn(aResponse()
                .withStatus(503)
                .withBody("{\"error\": \"Service Unavailable\"}")));

        CircuitBreaker cb = registry.circuitBreaker("paymentService");
        cb.reset();

        // Make 5 calls (minimumNumberOfCalls threshold)
        for (int i = 0; i < 5; i++) {
            try {
                paymentService.processPayment(new PaymentRequest("100"));
            } catch (Exception ignored) {}
        }

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
    }

    @Test
    void shouldOpenCircuitOnConnectionTimeouts() {
        // Simulate connection timeout
        stubFor(post(urlEqualTo("/charge"))
            .willReturn(aResponse()
                .withFixedDelay(5000) // 5 second delay
                .withStatus(200)));

        // With a 1s timeout configured on the HTTP client, this will fail
        CircuitBreaker cb = registry.circuitBreaker("paymentService");
        cb.reset();

        for (int i = 0; i < 5; i++) {
            try {
                paymentService.processPayment(new PaymentRequest("100"));
            } catch (Exception ignored) {}
        }

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
    }
}

Testing Retry Policies

Retry testing has two concerns: that retries happen the right number of times, and that they don't retry on non-retryable exceptions.

@SpringBootTest
class RetryTest {

    @Autowired
    private RetryRegistry retryRegistry;

    @Autowired
    private PaymentService paymentService;

    @MockBean
    private PaymentClient paymentClient;

    @Test
    void shouldRetryThreeTimesOnIOException() {
        // First two calls throw, third succeeds
        when(paymentClient.charge(any()))
            .thenThrow(new IOException("Connection reset"))
            .thenThrow(new IOException("Connection reset"))
            .thenReturn(new ChargeResponse("OK"));

        PaymentResult result = paymentService.processPayment(new PaymentRequest("100"));

        assertThat(result.isSuccess()).isTrue();
        verify(paymentClient, times(3)).charge(any());
    }

    @Test
    void shouldNotRetryOnBusinessException() {
        // InsufficientFundsException is not in retryExceptions config
        when(paymentClient.charge(any()))
            .thenThrow(new InsufficientFundsException("Not enough balance"));

        assertThrows(InsufficientFundsException.class,
            () -> paymentService.processPayment(new PaymentRequest("10000")));

        // Should only be called once — no retry
        verify(paymentClient, times(1)).charge(any());
    }

    @Test
    void shouldExhaustRetriesAndThrow() {
        when(paymentClient.charge(any()))
            .thenThrow(new IOException("Persistent failure"));

        assertThrows(MaxRetriesExceededException.class,
            () -> paymentService.processPayment(new PaymentRequest("100")));

        verify(paymentClient, times(3)).charge(any()); // maxAttempts=3
    }

    @Test
    void shouldPublishRetryEvents() {
        List<RetryEvent> events = new ArrayList<>();
        Retry retry = retryRegistry.retry("paymentService");
        retry.getEventPublisher().onRetry(events::add);

        when(paymentClient.charge(any()))
            .thenThrow(new IOException("fail"))
            .thenReturn(new ChargeResponse("OK"));

        paymentService.processPayment(new PaymentRequest("100"));

        assertThat(events).hasSize(1);
        assertThat(events.get(0).getNumberOfRetryAttempts()).isEqualTo(1);
    }
}

Testing Retry with Exponential Backoff

When using exponential backoff, you need to verify both the retry count and that delays are actually happening (or use test-friendly configuration):

# application-test.yml — override for tests
resilience4j:
  retry:
    instances:
      paymentService:
        maxAttempts: 3
        waitDuration: 10ms  # Much shorter than production 100ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
@Test
@ActiveProfiles("test")
void shouldApplyExponentialBackoff() {
    AtomicLong lastCallTime = new AtomicLong(0);
    List<Long> intervals = new ArrayList<>();

    when(paymentClient.charge(any())).thenAnswer(inv -> {
        long now = System.currentTimeMillis();
        if (lastCallTime.get() > 0) {
            intervals.add(now - lastCallTime.get());
        }
        lastCallTime.set(now);
        throw new IOException("fail");
    });

    assertThrows(Exception.class,
        () -> paymentService.processPayment(new PaymentRequest("100")));

    // With 10ms base and multiplier 2: ~10ms, ~20ms between retries
    assertThat(intervals).hasSize(2);
    assertThat(intervals.get(1)).isGreaterThan(intervals.get(0));
}

Testing Bulkheads

Bulkheads limit concurrent calls. Testing them requires actual concurrent execution:

@SpringBootTest
class BulkheadTest {

    @Autowired
    private BulkheadRegistry bulkheadRegistry;

    @Autowired
    private PaymentService paymentService;

    @MockBean
    private PaymentClient paymentClient;

    @Test
    void shouldRejectCallsExceedingConcurrentLimit() throws InterruptedException {
        // maxConcurrentCalls=5 — hold 5 calls open, 6th should be rejected
        CountDownLatch callsStarted = new CountDownLatch(5);
        CountDownLatch release = new CountDownLatch(1);

        when(paymentClient.charge(any())).thenAnswer(inv -> {
            callsStarted.countDown();
            release.await(); // Hold the call open
            return new ChargeResponse("OK");
        });

        ExecutorService executor = Executors.newFixedThreadPool(6);
        List<Future<?>> futures = new ArrayList<>();

        // Launch 5 concurrent calls that block
        for (int i = 0; i < 5; i++) {
            futures.add(executor.submit(
                () -> paymentService.processPayment(new PaymentRequest("100"))));
        }

        // Wait for all 5 to be in-flight
        callsStarted.await(2, TimeUnit.SECONDS);

        // 6th call should be rejected
        assertThrows(BulkheadFullException.class,
            () -> paymentService.processPayment(new PaymentRequest("100")));

        // Release the held calls
        release.countDown();
        executor.shutdown();
    }

    @Test
    void shouldAllowCallsAfterSlotFrees() throws InterruptedException {
        CountDownLatch firstCallStarted = new CountDownLatch(1);
        CountDownLatch releaseFirst = new CountDownLatch(1);

        // Fill bulkhead to max-1 capacity then check a slot opens after one completes
        Bulkhead bulkhead = bulkheadRegistry.bulkhead("paymentService");

        // Directly acquire permits to set up state
        for (int i = 0; i < 4; i++) {
            bulkhead.acquirePermission();
        }

        assertThat(bulkhead.getMetrics().getAvailableConcurrentCalls()).isEqualTo(1);

        // One more should work
        bulkhead.acquirePermission();
        assertThat(bulkhead.getMetrics().getAvailableConcurrentCalls()).isEqualTo(0);

        // Release one
        bulkhead.releasePermission();
        assertThat(bulkhead.getMetrics().getAvailableConcurrentCalls()).isEqualTo(1);
    }
}

Testing Rate Limiters

Rate limiter tests verify that request limits are enforced per time period:

@SpringBootTest
class RateLimiterTest {

    @Autowired
    private RateLimiterRegistry rateLimiterRegistry;

    @Autowired
    private PaymentService paymentService;

    @MockBean
    private PaymentClient paymentClient;

    @Test
    void shouldAllowRequestsUpToLimit() {
        when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));

        RateLimiter rateLimiter = rateLimiterRegistry.rateLimiter("paymentService");
        // limitForPeriod=10 per second

        // 10 calls should succeed
        for (int i = 0; i < 10; i++) {
            PaymentResult result = paymentService.processPayment(new PaymentRequest("10"));
            assertThat(result.isSuccess()).isTrue();
        }
    }

    @Test
    void shouldRejectRequestsExceedingLimit() {
        when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));

        // Make 11 calls — 11th should be rate limited
        List<Exception> exceptions = new ArrayList<>();
        for (int i = 0; i < 11; i++) {
            try {
                paymentService.processPayment(new PaymentRequest("10"));
            } catch (RequestNotPermitted e) {
                exceptions.add(e);
            }
        }

        assertThat(exceptions).hasSize(1);
    }

    @Test
    void shouldRefreshLimitAfterPeriod() throws InterruptedException {
        when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));

        // Exhaust the limit
        for (int i = 0; i < 10; i++) {
            paymentService.processPayment(new PaymentRequest("10"));
        }

        // Next call should fail
        assertThrows(RequestNotPermitted.class,
            () -> paymentService.processPayment(new PaymentRequest("10")));

        // Wait for refresh (limitRefreshPeriod=1s)
        Thread.sleep(1100);

        // Should succeed again
        PaymentResult result = paymentService.processPayment(new PaymentRequest("10"));
        assertThat(result.isSuccess()).isTrue();
    }
}

Testing Combinations: Circuit Breaker + Retry

In production, you often stack decorators. The order matters — retry wrapping a circuit breaker behaves differently than a circuit breaker wrapping a retry:

@Test
void shouldNotRetryWhenCircuitIsOpen() {
    CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentService");
    cb.transitionToOpenState();

    // With circuit open, retry should not attempt any calls
    // because CallNotPermittedException is not in retryExceptions
    assertThrows(CallNotPermittedException.class,
        () -> paymentService.processPayment(new PaymentRequest("100")));

    verify(paymentClient, never()).charge(any());
}

@Test
void shouldCountRetriedCallsAsMultipleCircuitBreakerCalls() {
    // This tests the order: retry(circuitBreaker(call))
    // Each retry attempt counts as a separate circuit breaker call
    when(paymentClient.charge(any())).thenThrow(new IOException("fail"));

    CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentService");
    cb.reset();

    try {
        paymentService.processPayment(new PaymentRequest("100"));
    } catch (Exception ignored) {}

    // 3 retries × 1 call each = 3 failed calls recorded by circuit breaker
    assertThat(cb.getMetrics().getNumberOfFailedCalls()).isEqualTo(3);
}

Using Test-Specific Configuration

Avoid long wait durations in tests. Use Spring profiles to override timing:

@TestConfiguration
public class ResilienceTestConfig {

    @Bean
    @Primary
    public CircuitBreakerConfig testCircuitBreakerConfig() {
        return CircuitBreakerConfig.custom()
            .slidingWindowSize(5)
            .minimumNumberOfCalls(3)
            .failureRateThreshold(50)
            .waitDurationInOpenState(Duration.ofMillis(100)) // 100ms vs 5s in prod
            .permittedNumberOfCallsInHalfOpenState(2)
            .build();
    }

    @Bean
    @Primary
    public CircuitBreakerRegistry testCircuitBreakerRegistry(CircuitBreakerConfig config) {
        return CircuitBreakerRegistry.of(config);
    }
}

Then use it selectively:

@SpringBootTest
@Import(ResilienceTestConfig.class)
class FastCircuitBreakerTest {
    // Tests run with 100ms open state instead of 5s
}

Testing Resilience4j Actuator Endpoints

If you expose health and metrics via Actuator, test those too:

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

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private CircuitBreakerRegistry registry;

    @Test
    void shouldExposeCircuitBreakerHealth() {
        ResponseEntity<Map> response = restTemplate.getForEntity(
            "/actuator/health/circuitBreakers", Map.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).containsKey("components");
    }

    @Test
    void shouldReportDegradedHealthWhenCircuitOpen() {
        registry.circuitBreaker("paymentService").transitionToOpenState();

        ResponseEntity<Map> response = restTemplate.getForEntity(
            "/actuator/health", Map.class);

        // Health should degrade when a circuit breaker is open
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
    }

    @Test
    void shouldExposeMetricsForCircuitBreaker() {
        ResponseEntity<Map> response = restTemplate.getForEntity(
            "/actuator/metrics/resilience4j.circuitbreaker.calls", Map.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().get("name"))
            .isEqualTo("resilience4j.circuitbreaker.calls");
    }
}

Common Pitfalls

Forgetting to reset state between tests. Circuit breakers hold state in singletons. Call circuitBreaker.reset() in @BeforeEach or you'll get test pollution.

Testing the wrong layer. If your service has @CircuitBreaker on a method called internally (not through the Spring proxy), the annotation won't fire. You need to call through the Spring bean, not this.method().

Ignoring the sliding window type. COUNT_BASED and TIME_BASED windows behave differently. Tests written for count-based windows will give misleading results if the production config uses time-based.

Not testing fallback logic. The circuit breaker opening is half the story. The fallback method is where business logic lives — test it gets the right inputs and returns sensible responses.

Concurrent test interference. If your test suite runs in parallel, circuit breaker singletons shared across tests will produce flaky results. Either use @DirtiesContext (expensive) or reset state explicitly in setup/teardown.

Summary

Testing Resilience4j effectively means testing state transitions, not just happy paths. The circuit breaker needs tests for CLOSED→OPEN, OPEN→HALF_OPEN, and HALF_OPEN→CLOSED transitions. Retry tests need to cover retryable vs non-retryable exceptions and exhaustion behavior. Bulkhead tests require actual concurrency. Rate limiter tests need to verify period refresh.

Use test-specific configuration to keep tests fast — 100ms open state instead of 5 seconds. Use WireMock for integration tests when you need to verify that your HTTP client configuration (timeouts, status codes) actually triggers the resilience patterns. And always reset circuit breaker state between tests.

Read more

Start now free