java 如何为使用Supplier作为方法参数构建的重试机制编写JUnit5或Mockito测试< CompletableFuture>?

xj3cbfub  于 2023-01-15  发布在  Java
关注(0)|答案(1)|浏览(200)

bounty将在6天后过期。回答此问题可获得+250的声誉奖励。PacificNW_Lover正在寻找来自声誉良好的来源的答案

拥有一个非特定于服务器的通用框架,用于导入和重用微服务中的对象,但可用于任何支持Java8的特定Java程序。
任务是创建一个retry()机制,该机制将采用以下两个参数:

  1. Supplier<CompletableFuture<R>> supplier
  2. int numRetries
    我需要它做的是创建一个泛型组件,每当基于指定的numRetries出现异常时,该组件都会对CompletableFuture执行重试操作。
    使用Java8,我编写了下面的泛型helper类,它使用Supplier根据指定的重试次数重试方法。
    一般来说,我是CompletableFuture的新手,所以我想知道如何为这个方法编写一个JUnit 5(以及Mockito是否更好)测试?我测试了所有的边缘情况,并尽可能地使它通用,以供其他人重用。
    请注意,这是在内部公共框架中,由其他微服务作为Maven依赖项导入,但目的是能够在JavaSE级别重用它。
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;
    }
}

用法:假设此代码编译并工作,我能够在client的配置文件中放入一个错误的URL,并且log.info(Error getting orderResponse = {});通过grepapp.log文件打印了两次。
这是将上述类作为Maven依赖项导入的调用类:

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());
        }
    }
}

虽然这个例子的调用类使用OrderSerice,并且HttpClient进行调用,但是这是一个泛型实用类,专门编写为可重用于返回CompletableFuture<OrderReponse>的任何类型的方法调用。
问题:
1.如何为此编写JUnit 5测试用例(或Mockito):public static <R> CompletableFuture<R> RetryUtility.retry(Supplier<CompletableFuture<R>> supplier, int numRetries)方法?
1.在设计和/或实现中,是否存在任何边缘情况或细微差别?

g52tjvyc

g52tjvyc1#

在你问“我如何测试方法X?”之前,澄清“X实际上是做什么的?"会有帮助。为了回答后一个问题,我个人喜欢以一种方式重写方法,这样各个步骤就变得更清楚了。
retry方法执行此操作,可以得到:

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;
}

注意,我删除了不必要的if语句和log.info调用,后者是不可测试的,除非你把一个logger示例作为方法参数传入(或者把retry设置为非静态的,把logger作为一个示例变量通过构造函数传入,或者使用一些肮脏的技巧,比如重定向logger输出流)。
那么,retry实际上是做什么的呢?
1.它调用supplier.get()一次,并将值赋给completableFuture变量。
1.如果为numRetries <= 0,则返回completableFuture,该示例与Supplier的get方法返回的CF示例相同。
1.如果numRetries > 0,则执行以下步骤numRetries次:
1.它创建一个函数function1,返回一个完整的CF。
1.它将function1传递给completableFuturethenApply方法,从而创建一个新的CF tmp1
1.它创建了另一个函数function2,该函数忽略其输入参数,但调用supplier.get()
1.它将function2传递给tmp1exceptionally方法,从而创建一个新的CF tmp2
1.它创建了第三个函数function3,它是恒等函数。
1.它将function3传递给tmp2thenCompose,创建一个新的CF并将其赋给completableFuture变量。
对一个函数做什么有一个清晰的分解,让你看到你可以测试什么,你不能测试什么,因此,你可能想要重构。步骤1和2是非常容易测试的:
在步骤1中,模拟一个Supplier并测试其get方法是否被调用:

@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);
}

在步骤2中,让供应商返回一些预定义的示例,并检查retry是否返回相同的示例:

@Test
void testStep2() {
    CompletableFuture<Object> instance = CompletableFuture.completedFuture("");
    Supplier<CompletableFuture<Object>> supplier = () -> instance;
    CompletableFuture<Object> result = RetryUtility.retry(supplier, 0);
    Assertions.assertSame(instance, result);
}

当然,棘手的部分是第3步,首先要注意的是哪些变量会受到输入参数的影响,这些变量是:completableFuturetmp1function2tmp2。相比之下,function1function3实际上是常量,不受输入参数的影响。
那么,我们如何影响列出的参数呢?completableFutureSupplierget方法的返回值。如果我们让get返回一个mock,我们可以影响thenApply的返回值。类似地,如果我们让thenApply返回一个mock,我们可以影响expectionally的返回值,也可以对thenCompose应用相同的逻辑,然后测试我们的方法是否以正确的顺序被调用了正确的次数:

@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();
}

那么thenApplythenCompose的方法参数呢?没有办法测试这些方法是否分别用function1function3调用过,(除非你重构代码,把函数移出方法调用),因为这些函数是我们方法的局部函数,不受参数的影响。虽然我们无法测试exceptionally是否被function2作为参数调用,但我们可以测试function2调用supplier.get()的次数是否正好为numRetries

@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);
}

类似地,您可以通过提供一个供应商来测试retry调用get方法n+1次,该供应商在调用n次后返回一个完整的future。
我们仍然需要做的(也是我们可能应该做的第一步)是测试我们的方法的返回值是否正确:

  • 如果CF尝试numRetries次失败,则CF应异常完成。
  • 如果CF失败次数少于numRetries次,则CF应正常完成。
@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());
}

您可能需要考虑测试和捕获的其他一些情况是,如果numRetries为负或非常大,会发生什么?这样的方法调用是否会抛出异常?
另一个我们还没有触及的问题是:这是测试代码的正确方法吗?
有些人可能会说是的,有些人可能会说,你不应该测试你的方法的内部结构,而应该只测试它的输出(也就是说,基本上只测试最后两次)。这显然是有争议的,而且像大多数事情一样,取决于你的需求。(例如,你会测试一些排序算法的内部结构吗?比如数组赋值等等?)
正如您所看到的,通过测试内部结构,测试变得相当复杂,并且涉及到大量的模拟。测试内部结构也会使重构变得更加麻烦,因为您的测试可能会开始失败,即使您没有更改方法的逻辑。就个人而言,在大多数情况下我不会编写这样的测试。然而,如果很多人依赖于我的代码的正确性,例如执行步骤的顺序,我可能会考虑它。
无论如何,如果您选择走那条路,我希望这些例子对您有用。

相关问题