javamockitorunnablefunctional-interface

Struggling to inject my mocked Runnable into my service call


For context, I'm using Resilience4j to handle exceptions and invoke retries. This is done through RetryService.

In this function, callRunnableWithRetry() there are two params. 1st is a string, the 2nd is a Runnable. I have designed the function like this to make it modular so that anyone can process whatever they code block want to.

@Test
void testRetry(){
    RetryService retryServiceSpy = spy(retryService);

    ObjectA obj = new ObjectA("test-value");

    Runnable mockRunnable = mock(Runnable.class);

    doThrow(new RuntimeException("Simulated exception"))
        .doThrow(new RuntimeException("Simulated exception"))
        .doNothing()
        .when(mockRunnable).run();

    doAnswer(invocation -> {
        String configName = invocation.getArgument(0);
        Runnable actualRunnable = invocation.getArgument(1);
        Retry retry = retryServiceSpy.getRetry(configName);

        CheckedRunnable checkedRunnable = Retry.decorateCheckedRunnable(retry, actualRunnable::run);

        try {
            checkedRunnable.run();
        } catch (Exception ignored) {
            log.error("error: {}", ignored)
        }
        return null;
    }).when(retryService).callRunnableWithRetry("test", mockRunnable);


    serviceA.getData(obj);

    verify(mockRunnable, times(3)).run();
}

Issue

As you can see i'm creating a mock that throws exceptions twice then does nothing. I need this so i can trigger my retry functionality on my the runnable that I pass to callRunnableWithRetry() through resilience4j.

When i use verify to see if runnable.run() was actually called 3x Mockito gives me this error.

Wanted but not invoked:
runnable.run();
-> at [redacted]
Actually, there were zero interactions with this mock.

So i've also tried to create a spy of my Runnable then inject it to my doAnswer(), so that way I can make sure that my mocked Runnable is actually being applied in my serviceA.getData() call but even that didn't work; but if someone can get it to work then please share.

Context

If curious this is RetryService

@Service
@Sl4j
public class RetryService {

    private static final String RETRY_LOG_MESSAGE = "%s %s from service: %s" +  
  "\nattempts made: %s" +  
  "\nexception:\n```%s```";  
  
    public Retry getRetry(String retryName) {  
      //process to acquire Reslience4j retry object
      ...
      return retry;  
    }  
      
    private void handleRetryEvents(Retry retry, String action) {  
      retry.getEventPublisher()  
        .onSuccess(event -> logEvent(action, false, event))  
        .onRetry(event -> logEvent(action, false, event))  
        .onError(event -> logEvent(action, true, event));  
    }  
      
    private void logEvent(String action, boolean isAlert, RetryEvent event) {  
      //maps data to RETRY_LOG_MESSAGE string
      ...
    }  
      
    public CheckedRunnable callRunnableWithRetry(String configName, Runnable runnableFunc) {  
      Retry retry = getRetry(configName);  
      handleRetryEvents(retry, "read");  
      return decorateCheckedRunnable(retry, () -> runnableFunc.run());  
    }
}

And this is how my implementation code looks in ServiceA

@Service
@Sl4j
public class ServiceA {
    private final RetryService retryService;

    public ServiceA(RetryService retryService){
        this.retryService = retryService;
    }
    public void getData(ObjectA obj) {  
      try {  
        //processes data
        ...  
        retryService.callRunnableWithRetry(obj.getName(), () -> {  
          log.debug("Name: {}", obj.getName());  
        }).run();  
      } catch (Throwable e) {  
        log.error("error: {}", e);
      }  
    }
}

Solution

  • As explained in the comments, you are not doing anything with your mockRunnable. Let's look at it in detail.

    Runnable mockRunnable = mock(Runnable.class);
    // ...
    doAnswer(invocation -> {
        // irrelevant ...
        return null;
    }).when(retryService).callRunnableWithRetry("test", mockRunnable);
    

    The above states: if (or when) method callRunnableWithRetry on retryService is called with arguments "test" and the exact mockRunnable instance (Runnable doesn't implement equals() and Mockito mock instances are always only compared via identity/reference), then invoke the answer code block. Stubbing a method this way does not call the method, nor does it replace the actual arguments of the method.

    How is callRunnableWithRetry actually called by your production code of your SUT ServiceA?

    retryService.callRunnableWithRetry(obj.getName(), () -> {  
      log.debug("Name: {}", obj.getName());  
    }).run();
    

    Its second argument is () -> { log.debug("Name: {}", obj.getName()); } which is very obviously not your mockRunnable instance, so the stubbed answer is not matched and consequently not exercised. This actual runnable will write a debug log and that's it. It will not throw an exception, it will not trigger the retry mechanism (which you have stubbed away; see next paragraph).

    It's not very clear to me either why you reimplement the production logic of the RetryService in your stubbed answer. Which class are you trying to test? If you want to test RetryService, get rid of ServiceA in your test and test the RetryService directly. If you want to test ServiceA, simply verify that the callRunnableWithRetry method has been called (and maybe its return value ran?). Note that the method name callRunnableWithRetry itself is quite misleading, since the method doesn't actually call a runnable – it returns a new runnable that needs to be invoked for the original runnable to be executed.

    Creating a Mockito mock instance of the Runnable class does not magically replace all Runnable instances with this mock, nor does it make the instance available to your objects, unless you explicitly do so in your code. Similar variants of this problem and possible solutions are explained in: Why are my mocked methods not called when executing a unit test?

    One possible solution:

    doAnswer(invocation -> {
        // ...
        return null;
    }).when(retryService).callRunnableWithRetry(eq("test"), any(Runnable.class));