用组合、排列和乘积进行详尽的JUNIT5测试的教程

270 阅读7分钟

利用组合、排列组合和产品进行详尽的JUNIT5测试

单元测试是提供高质量软件过程中的一个组成部分。但是,如何编写涵盖若干操作的所有变体的测试?阅读本文,了解如何将JUnit5与组合、排列组合和产品结合起来使用。

测试支持库

有许多库可以在不同方面使测试变得更好。下面是其中的一些。

Agitar One

Agitator自动创建动态测试案例,合成输入数据集,并分析结果。

Chaos Monkey

Chaos Monkey负责随机终止生产中的实例,以确保工程师实现其服务对实例故障的弹性。

Chronicle测试框架

这个库为编写JUnit测试提供支持类。它同时支持JUnit4和JUnit5。像permutations、combinations和products这样的工具可以被利用来创建详尽的测试。Agitar One可以自动识别和生成测试,而Chronicle Test Framework为编写你自己的测试提供程序化支持。

在这篇文章中,我们将讨论Chronicle测试框架。

目标

假设我们写了一个自定义的java.util.List ,叫做MyList ,我们想用一个参考实现来测试这个实现,比如ArrayList 。此外,假设我们有一些可以应用于这些对象的操作O1, O2, ..., On.现在的问题是:我们如何编写测试,以确保这两个对象提供相同的结果,无论如何应用这些操作?

组合、排列组合和乘积

在我们继续之前,我们提醒自己从数学角度看什么是组合、排列和乘积。假设我们有一个集合X={A,B,C},那么。

X的组合是:

[]
[A]
[B]
[C]
[A, B]
[A, C]
[B, C]
[A, B, C]

X的排列组合是:

[A, B, C]
[A, C, B]
[B, A, C]
[B, C, A]
[C, A, B]
[C, B, A]

X的所有组合的排列方式为

[]
[A]
[B]
[C]
[A, B]
[B, A]
[A, C]
[C, A]
[B, C]
[C, B]
[A, B, C]
[A, C, B]
[B, A, C]
[B, C, A]
[C, A, B]
[C, B, A]

其中[]表示一个没有元素的序列。从上面的序列可以看出,随着集合成员数量的增加,变体的数量将迅速增加。这就对人们所能写出的测试的详尽性提出了一个实际的限制。

另一个概念(本文没有举例)是 "产品"(又称笛卡尔产品)。假设我们有一个类型的序列s1=[A,B]和另一个类型的序列s2=[1,2],那么;
,s1和s2的积是:

Mathematica

[A, 1]
[A, 2]
[B, 1]
[B, 2]

产品与数据库的 "连接 "操作有许多相似之处,并与嵌套循环有关。

测试框架

在这个测试中,我们将使用开源的Chronicle-Test-Framework,它支持上述的组合、互换和乘积功能。下面是一个例子:

Java

// Prints: [], [A], [B], [C], [A, B], [B, C], … (8 sequences)
Combination.of("A", "B", "C")
    .forEach(System.out::println)

这将打印出{A, B, C}的所有组合,其结果与前一章相同。同样地,下面的例子将打印出{A, B, C}的所有排列组合:

爪哇

// Prints: [A, B, C], [A, C, B], [B, A, C], … (6 sequences)
Permutation.of("A", "B", "C")
    .forEach(System.out::println)

在下面这个例子中,我们结合了组合和排列的功能。

Java

// Prints: [], [A], [B], [C], [A, B], [B, A], …(16 sequences)
Combination.of("A", "B", "C")
    .flatMap(Permutation::of)
    .forEach(System.out::println)

Collection 上面的方法产生了Stream 的元素,允许轻松适应其他框架,如JUnit5。

当涉及到产品时,事情的运作方式略有不同。下面是一个例子。

Java

final List<String> strings = Arrays.asList("A", "B");
final List<Integer> integers = Arrays.asList(1, 2);

Product.of(strings, integers)
        .forEach(System.out::println);

这将打印:

纯文本

Product2Impl{first=A, second=1}
Product2Impl{first=A, second=2}
Product2Impl{first=B, second=1}
Product2Impl{first=B, second=2}

如果使用较新的Java版本,一般会用以下方案代替。

Java

record StringInteger(String string, Integer integer){}

Product.of(strings, integers, StringInteger::new)
        .forEach(System.out::println);

这将产生:

纯文本

StringInteger[string=A, integer=1]
StringInteger[string=A, integer=2]
StringInteger[string=B, integer=1]
StringInteger[string=B, integer=2]

在我看来,上述方案比默认的Product2Impl 元组要好,因为记录是一个 "名义元组",其中状态元素的名称和类型是在记录头中声明的,而Product2Impl 是依靠通用类型以及 "第一 "和 "第二 "作为名称。

目标

假设我们想确保两个java.lang.List 实现对一些变异操作的行为是一样的。 这里,列表包含Integer 值(即implements List<Integer> )。

我们首先要确定要使用的变异操作。一个(任意的)建议是使用以下操作:

  • list.clear()
  • list.add(1)
  • list.remove((Integer) 1) // 将删除对象1而不是@index 1
  • list.addAll(Arrays.asList(2, 3, 4, 5))
  • list.removeIf(ODD)

其中Predicate<Integer> ODD = v -> v % 2 == 1

在本文中,我们最初将使用ArrayListLinkedList 进行比较。

正如读者可能猜测的那样,List 类型要用所有可能的操作序列来相互测试。但是,如何才能实现这一点呢?

解决方案

JUnit5提供了一个@TestFactory 注解和DynamicTest 对象,允许测试工厂返回一个StreamDynamicTest 实例。这是我们可以利用的东西,如下所示:

Java

private static final Collection<NamedConsumer<List<Integer>>> OPERATIONS =
     Arrays.asList(
        NamedConsumer.of(List::clear, "clear()"),
        NamedConsumer.of(list -> list.add(1), "add(1)"),
        NamedConsumer.of(list -> list.remove((Integer) 1), "remove(1)"),
        NamedConsumer.of(list -> list.addAll(Arrays.asList(2, 3, 4, 5)),
                                 "addAll(2,3,4,5)"),
        NamedConsumer.of(list -> list.removeIf(ODD), "removeIf(ODD)")
);



@TestFactory
Stream<DynamicTest> validate() {
    return DynamicTest.stream(Combination.of(OPERATIONS)
                    .flatMap(Permutation::of),
            Object::toString,
            operations -> {
                List<Integer> first = new ArrayList<>();
                List<Integer> second = new LinkedList<>();
                operations.forEach(op -> {
                    op.accept(first);
                    op.accept(second);
                });
                assertEquals(first, second);
            });
}

这是整个解决方案,当在IntelliJ(或其他类似工具)下运行时,将进行以下测试(为简洁起见,只显示326个测试中的前16个)。 图片1,显示了326个测试中的前16个测试运行。

之所以使用NamedConumer而不是标准的java.util.function 。消费者的原因是,它允许显示真实的名字,而不是像ListDemoTest$$Lambda$350/0x0000000800ca9a88@3d8314f0 那样的一些隐秘的lambda引用。当然,它在输出中看起来更好,并提供更好的调试能力。

扩展概念

假设我们有更多的List实现要测试,比如说:

  • ArrayList
  • LinkedList
  • CopyOnWriteArrayList
  • Stack
  • Vector

为了扩展这个概念,我们首先创建一个List<Integer> 构造函数的集合。

Java

private static final Collection<Supplier<List<Integer>>> CONSTRUCTORS =
        Arrays.asList(
            ArrayList::new,
            LinkedList::new,
            CopyOnWriteArrayList::new,
            Stack::new,
            Vector::new);

使用构造函数而不是实例的原因是,我们需要能够为每个动态测试创建新的List 实现。

现在,我们只需要对以前的测试做一些小的修改。

Java

@TestFactory
Stream<DynamicTest> validateMany() {

    return DynamicTest.stream(Combination.of(OPERATIONS)
                    .flatMap(Permutation::of),
            Object::toString,
            operations -> {

                // Create a fresh list of List implementations
                List<List<Integer>> lists = CONSTRUCTORS.stream()
                        .map(Supplier::get)
                        .collect(Collectors.toList());

                // For each operation, apply the operation on each list
                operations.forEach(lists::forEach);

                // Test the lists pairwise
                Combination.of(lists)

                    // Filter out only combinations with two lists
                    .filter(set -> set.size() == 2)

                    // Convert the Set to a List for easy access below
                    .map(ArrayList::new)

                    // Assert the pair equals
                    .forEach(pair -> assertEquals(pair.get(0), pair.get(1)))
            });
}

严格来说,这有点作弊,因为一个动态测试包含多个子测试,用于多个List 对。然而,要修改上面的代码,在不同的动态测试下flatMap() 各种断言,是相当容易的。这是留给读者的一个练习。

最后的警告

组合、排列和乘积是强有力的工具,可以增加测试覆盖率,但它们也可以增加运行所有测试的时间,因为即使输入变体的数量适度增加,序列的数量也会爆炸。

例如,这里有一个列表,列出了一些大小为s的给定集S的所有组合的排列组合的数量。

s = size(S)

poc(s) (组合的排列组合)

0

1

1

2

2

5

3

16

4

65

5

326

6

1,957

7

13,700

8

109,601

9

986,410

10

986,4101

11

108,505,112

表1,显示了一些集合大小的组合的permutations的数量。

更一般地说,可以证明:poc(s) = poc(s - 1) * s + 1。

如果有疑问,可以使用.count()流操作来检查组合的数量,它将返回如下所示的序列数量。

Java

long poc6 = Combination.of(1, 2, 3, 4, 5, 6)
        .flatMap(Permutation::of)
        .count();

不出所料,上面的流将返回1,957,因为有六个输入元素。

组合学可以产生巨大的影响。然而,要确保控制好动态测试的数量,否则测试时间可能会大大增加。

像Chronicle Queue和Chronicle Map这样的库利用Chronicle-Test框架来改进测试。下面是一个来自Chronicle Map的例子,其中五个Map操作被详尽地测试,在短短的几行代码中提供了326个动态测试。