利用组合、排列组合和产品进行详尽的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()- l
ist.add(1) list.remove((Integer) 1)// 将删除对象1而不是@index 1list.addAll(Arrays.asList(2, 3, 4, 5))list.removeIf(ODD)
其中Predicate<Integer> ODD = v -> v % 2 == 1 。
在本文中,我们最初将使用ArrayList 和LinkedList 进行比较。
正如读者可能猜测的那样,List 类型要用所有可能的操作序列来相互测试。但是,如何才能实现这一点呢?
解决方案
JUnit5提供了一个@TestFactory 注解和DynamicTest 对象,允许测试工厂返回一个Stream 的DynamicTest 实例。这是我们可以利用的东西,如下所示:
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实现要测试,比如说:
ArrayListLinkedListCopyOnWriteArrayListStackVector
为了扩展这个概念,我们首先创建一个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个动态测试。