Testing SpringBoot with annotation-style Resilience4j

5.8k views Asked by At

I'm using annotation-style Resilience4j with my SpringBoot app called "demo". When calling an external backend via RestTemplate I want to use TimeLimiter and Retry to achieve the following:

  1. Limit the REST-call duration to 5 seconds --> if it takes longer, fail with TimeoutException
  2. Retry on TimeoutException --> do maximum 2 attempts

To see if the configuration of my resilience-setup works as designed I wrote an IntegrationTest. This test runs under profile "test" and is configured using "application-test.yml":

  1. Uses TestRestTemplate to send a call to my "SimpleRestEndpointController"
  2. The controller calls my business-service "CallExternalService" which has an annotated method "getPersonById" (Annotations: @TimeLimiter, @Retry)
  3. From this method a mocked RestTemplate is used to call the external-backend at "FANCY_URL"
  4. Using Mockito the RestTemplate call to the external-backend is slowed down (using Thread.sleep)
  5. I expect that the TimeLimiter cancels the call after 5 seconds, and the Retry ensures that the RestTemplate call is tried again (verify RestTemplate to have been called twice)

PROBLEM: TimeLimiter and Retry are registered, but do not do their job (TimeLimiter doesn't limit the call duration). Therefore RestTemplate is only called once, delivering the empty Person (see code for clarification). The linked example project can be cloned and showcases the problem when running the test.

Code of application-test.yml (also here: Link to application-test.yml):

resilience4j:
  timelimiter:
    configs:
      default:
        timeoutDuration: 5s
        cancelRunningFuture: true
    instances:
      MY_RESILIENCE_KEY:
        baseConfig: default
  retry:
    configs:
      default:
        maxRetryAttempts: 2
        waitDuration: 100ms
        retryExceptions:
          - java.util.concurrent.TimeoutException
    instances:
      MY_RESILIENCE_KEY:
        baseConfig: default

The code of this Test (also here: Link to IntegrationTest.java):

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {DemoApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ActiveProfiles("test")
public class IntegrationTest {
    private TestRestTemplate testRestTemplate;
    public final String FANCY_URL = "https://my-fancy-url-doesnt-matter.com/person";
    private String apiUrl;
    private HttpHeaders headers;
    
    @LocalServerPort
    private String localServerPort;
    
    @MockBean
    RestTemplate restTemplate;
    
    @Autowired
    CallExternalService callExternalService;
    
    @Autowired
    SimpleRestEndpointController simpleRestEndpointController;
    
    @Before
    public void setup() {
        this.headers = new HttpHeaders();
        this.testRestTemplate = new TestRestTemplate("username", "password");
        this.apiUrl = String.format("http://localhost:%s/person", localServerPort);
    }
    
    @Test
    public void testShouldRetryOnceWhenTimelimitIsReached() {
        // Arrange
        Person mockPerson = new Person();
        mockPerson.setId(1);
        mockPerson.setFirstName("First");
        mockPerson.setLastName("Last");
        ResponseEntity<Person> mockResponse = new ResponseEntity<>(mockPerson, HttpStatus.OK);
            
        
        Answer customAnswer = new Answer() {
            private int invocationCount = 0;
            @Override
            public Object answer(InvocationOnMock invocationOnMock) throws Throwable {
                invocationCount++;
                if (invocationCount == 1) {
                    Thread.sleep(6000);
                    return new ResponseEntity<>(new Person(), HttpStatus.OK);
                } else {
                    return mockResponse;
                }
            }
        };
        
        doAnswer(customAnswer)
        .when(restTemplate).exchange(
                FANCY_URL,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                new ParameterizedTypeReference<Person>() {});
        
        
        // Act
        ResponseEntity<Person> result = null;
        try {
            result = this.testRestTemplate.exchange(
                    apiUrl,
                    HttpMethod.GET,
                    new HttpEntity<>(headers),
                    new ParameterizedTypeReference<Person>() {
                    });
        } catch(Exception ex) {
            System.out.println(ex);         
        }
        
        
        // Assert
        verify(restTemplate, times(2)).exchange(
                FANCY_URL,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                new ParameterizedTypeReference<Person>() {});
        Assert.assertNotNull(result);
        Assert.assertEquals(mockPerson, result.getBody());      
        
    }
}

The code of my app showcasing the problem: https://github.com/SidekickJohn/demo

I created a swimlane diagram of the "logic" as part of the README.md: https://github.com/SidekickJohn/demo/blob/main/README.md

1

There are 1 answers

7
Robert Winkler On

If you want to mock a real RestTemplate bean which is used by your CallExternalService , you have to use a Mockito Spy -> https://www.baeldung.com/mockito-spy

But I usually prefer and would recommend to use WireMock instead of Mockito to mock HTTP endpoints.