Have a common framework that isn't server specific which we use to import and re-use objects inside our microservices but can be used for any particular Java program which supports Java 8.
Was tasked to create a retry()
mechanism that would take the following two parameters:
Supplier<CompletableFuture<R>> supplier
int numRetries
What I needed it to do is to make a generic component that conducts a retry operation for a CompletableFuture
every time there's an exception based on the numRetries
specified.
Using Java 8, I wrote the following generic helper class which uses a Supplier to retry a method based on a number of retries specified.
Am new to CompletableFuture
, in general, so am wondering how to write a JUnit 5 (and if Mockito is better) test for this method? Am testing for all edge cases and have made this as generic as possible for others to re-use.
Note this is inside an in-house common framework which is imported by other microservices as a Maven dependency but the aim is to be able to re-use this at the Java SE level.
public class RetryUtility {
private static final Logger log =
LoggerFactory.getLogger(RetryUtility.class);
public static <R> CompletableFuture<R> retry(
Supplier<CompletableFuture<R>> supplier,
int numRetries) {
CompletableFuture<R> completableFuture = supplier.get();
if (numRetries > 0) {
for (int i = 0; i < numRetries; i++) {
completableFuture =
completableFuture
.thenApply(CompletableFuture::completedFuture)
.exceptionally(
t -> {
log.info("Retrying for {}", t.getMessage());
return supplier.get();
})
.thenCompose(Function.identity());
}
}
return completableFuture;
}
}
Usage: Presume this code compiles and works, I was able to put in a wrong URL in the config file for client
and log.info(Error getting orderResponse = {});
was printed twice via grep
against app.log
file.
This is the calling class that imports the above class as Maven dependency:
public class OrderServiceFuture {
private CompletableFuture<OrderReponse> getOrderResponse(
OrderInput orderInput,BearerToken token) {
int numRetries = 1;
CompletableFuture<OrderReponse> orderResponse =
RetryUtility.retry(() -> makeOrder(orderInput, token), numRetries);
orderResponse.join();
return orderResponse;
}
public CompletableFuture<OrderReponse> makeOrder() {
return client.post(orderInput, token),
orderReponse -> {
log.info("Got orderReponse = {}", orderReponse);
},
throwable -> {
log.error("Error getting orderResponse = {}", throwable.getMessage());
}
}
}
Although this example's calling class uses OrderSerice
with an HttpClient
making a call, this is a generic utility class that was specifically written to be reusable for any type of method call which returns CompletableFuture<OrderReponse>
.
Question(s):
How to write a JUnit 5 test case (or Mockito) for this: public static <R> CompletableFuture<R> RetryUtility.retry(Supplier<CompletableFuture<R>> supplier, int numRetries)
method?
Is there any edge cases or nuances that someone can see with its design and/or implementation?
Before you ask "How do I test method X?"it helps to clarify "What does X actually do?". To answer this latter question I personally like to rewrite methods in a way such that individual steps become clearer.
Doing that for your retry
method, I get:
public static <R> CompletableFuture<R> retry(
Supplier<CompletableFuture<R>> supplier,
int numRetries
) {
CompletableFuture<R> completableFuture = supplier.get();
for (int i = 0; i < numRetries; i++) {
Function<R, CompletableFuture<R>> function1 = CompletableFuture::completedFuture;
CompletableFuture<CompletableFuture<R>> tmp1 = completableFuture.thenApply(function1);
Function<Throwable, CompletableFuture<R>> function2 = t -> supplier.get();
CompletableFuture<CompletableFuture<R>> tmp2 = tmp1.exceptionally(function2);
Function<CompletableFuture<R>, CompletableFuture<R>> function3 = Function.identity();
completableFuture = tmp2.thenCompose(function3);
}
return completableFuture;
}
Note that I removed the unnecessary if
statement and also the log.info
call. The latter isn't testable unless you pass in a logger instance as a method argument (or make retry
non-static and have the logger as an instance variable that is passed via the constructor, or maybe use some dirty hacks like redirecting the logger output stream).
Now, what does retry
actually do?
supplier.get()
once and assigns the value to the completableFuture
variable.numRetries <= 0
, then it returns completableFuture
which is the same CF instance that the Supplier's get
method returned.numRetries > 0
, then it does the following steps numRetries
times:
function1
that returns a completed CF.function1
to the thenApply
method of completableFuture
, creating a new CF tmp1
.function2
which is a function that ignores its input argument but calls supplier.get()
.function2
to the exceptionally
method of tmp1
, creating a new CF tmp2
.function3
which is the identity function.function3
to the thenCompose
of tmp2
, creating a new CF and assigning it to the completableFuture
variable.Having a clear breakdown of what a function does lets you see what you can test and what you cannot test and, therefore, might want to refactor. Steps 1. and 2. are very easy to test:
For step 1. you mock a Supplier
and test that it's get
method is called:
@Test
void testStep1() { // please use more descriptive names...
Supplier<CompletableFuture<Object>> mock = Mockito.mock(Supplier.class);
RetryUtility.retry(mock, 0);
Mockito.verify(mock).get();
Mockito.verifyNoMoreInteractions(mock);
}
For step 2. you let the supplier return some predefined instance and check that retry
returns the same instance:
@Test
void testStep2() {
CompletableFuture<Object> instance = CompletableFuture.completedFuture("");
Supplier<CompletableFuture<Object>> supplier = () -> instance;
CompletableFuture<Object> result = RetryUtility.retry(supplier, 0);
Assertions.assertSame(instance, result);
}
The tricky part is, of course, step 3. The first thing you should look out for is which variables are influenced by our input arguments. These are: completableFuture
, tmp1
, function2
, and tmp2
. In contrast, function1
and function3
are practically constants and are not influenced by our input arguments.
So, how can we influence the listed arguments then? Well, completableFuture
is the return value of the get
method of our Supplier
. If we let get
return a mock, we can influence the return value of thenApply
. Similarly, if we let thenApply
return a mock, we can influence the return value of expectionally
. We can apply the same logic to thenCompose
and then test that our methods have been called the correct amount of times in the correct order:
@ParameterizedTest
@ValueSource(ints = {0, 1, 2, 3, 5, 10, 100})
void testStep3(int numRetries) {
CompletableFuture<Object> completableFuture = mock(CompletableFuture.class);
CompletableFuture<CompletableFuture<Object>> tmp1 = mock(CompletableFuture.class);
CompletableFuture<CompletableFuture<Object>> tmp2 = mock(CompletableFuture.class);
doReturn(tmp1).when(completableFuture).thenApply(any(Function.class));
doReturn(tmp2).when(tmp1).exceptionally(any(Function.class));
doReturn(completableFuture).when(tmp2).thenCompose(any(Function.class));
CompletableFuture<Object> retry = RetryUtility.retry(
() -> completableFuture, // here 'get' returns our mock
numRetries
);
// While we're at it we also test that we get back our initial CF
Assertions.assertSame(completableFuture, retry);
InOrder inOrder = Mockito.inOrder(completableFuture, tmp1, tmp2);
for (int i = 0; i < numRetries; i++) {
inOrder.verify(completableFuture, times(1)).thenApply(any(Function.class));
inOrder.verify(tmp1, times(1)).exceptionally(any(Function.class));
inOrder.verify(tmp2, times(1)).thenCompose(any(Function.class));
}
inOrder.verifyNoMoreInteractions();
}
What about the method arguments of thenApply
and thenCompose
? There is no way to test that these methods have been called with function1
and function3
, respectively, (unless you refactor your code and move the functions out of that method call) as these functions are local to our method and not influenced by our arguments. But what about function2
? While we cannot test that exceptionally
is called with function2
as an argument, we can test that function2
calls supplier.get()
exactly numRetries
times. When does it do that? Well, only if the CF fails:
@ParameterizedTest
@ValueSource(ints = {0, 1, 2, 3, 5, 10, 100})
void testStep3_4(int numRetries) {
CompletableFuture<Object> future = CompletableFuture.failedFuture(new RuntimeException());
Supplier<CompletableFuture<Object>> supplier = mock(Supplier.class);
doReturn(future).when(supplier).get();
Assertions.assertThrows(
CompletionException.class,
() -> RetryUtility.retry(supplier, numRetries).join()
);
// remember: supplier.get() is also called once at the beginning
Mockito.verify(supplier, times(numRetries + 1)).get();
Mockito.verifyNoMoreInteractions(supplier);
}
Similarly, you can test that retry
calls the get
method n+1
times by providing a supplier, that returns a completed future after it has been called n
times.
What we still need to do (and what we should probably have had done as a first step) is to test whether the return value of our method behaves correctly:
numRetries
tries, the CF should complete exceptionally.numRetries
tries, the CF should complete normally.@ParameterizedTest
@ValueSource(ints = {0, 1, 2, 3, 5, 10})
void testFailingCf(int numRetries) {
RuntimeException exception = new RuntimeException("");
CompletableFuture<Object> future = CompletableFuture.failedFuture(exception);
CompletionException completionException = Assertions.assertThrows(
CompletionException.class,
() -> RetryUtility.retry(() -> future, numRetries).join()
);
Assertions.assertSame(exception, completionException.getCause());
}
@ParameterizedTest
@ValueSource(ints = {0, 1, 2, 3, 5, 10})
void testSucceedingCf(int numRetries) {
final AtomicInteger counter = new AtomicInteger();
final String expected = "expected";
Supplier<CompletableFuture<Object>> supplier =
() -> (counter.getAndIncrement() < numRetries / 2)
? CompletableFuture.failedFuture(new RuntimeException())
: CompletableFuture.completedFuture(expected);
Object result = RetryUtility.retry(supplier, numRetries).join();
Assertions.assertEquals(expected, result);
Assertions.assertEquals(numRetries / 2 + 1, counter.get());
}
Some other cases you might want to consider testing and catching are what happens if numRetries
is negative or very large? Should such method calls throw an exception?
Another question we haven't touched so far is: Is this the right way to test your code?
Some people might say yes, others would argue that you shouldn't test the internal structure of your method but only its output given some input (ie basically only the last two tests). That's clearly debatable and, like most things, depends on your requirements. (For example, would you test the internal structure of some sorting algorithm, like array assignments and what not?).
As you can see, by testing the internal structure, the tests become quite complicated and involve a lot of mocking. Testing the internal structure can also make refactoring more troublesome, as your tests might start to fail, even though you haven't changed the logic of your method. Personally, in most situations I wouldn't write such tests. However, if a lot of people depended on the correctness of my code, for example on the order in which steps are executed, I might consider it.
In any case, if you choose to go that route, I hope these examples are useful to you.
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.