Groovy 测试中的组合和排列
作者: Paul King
发布时间: 2023-03-19 05:23PM
这篇文章的灵感来自最近的 foojay.io 文章 使用组合、排列和乘积进行 JUnit5 彻底测试,作者是 Per Minborg,文章探讨了如何使用 Chronicle 测试框架 进行更全面的测试。让我们看看如何将该框架以及其他框架与 Groovy 结合使用。为了趣味性,我们会加入一些 成对测试 和 属性测试。
Chronicle 测试框架
Chronicle 测试框架 是一个与 JUnit 配合使用的库,它支持对数据或操作的组合和排列进行轻松测试。使用示例解释其工作原理可能是最简单的。之前提到的博客有一个示例展示了如何计算排列
@Test
void numberOfPermutations() {
assert Combination.of(1, 2, 3, 4, 5, 6)
.flatMap(Permutation::of)
.peek{ println it }
.count() == 1957
}
我们使用 peek
对其进行了稍微调整,以打印出排列。输出如下所示(为简洁起见,省略了几部分)
[] [1] [2] [3] [4] [5] [6] [1, 2] [2, 1] [1, 3] ... [5, 6] [6, 5] [1, 2, 3] [1, 3, 2] ... [6, 5, 4, 3, 1, 2] [6, 5, 4, 3, 2, 1]
如果我们的测试场景需要数字列表,上述生成的列表可能很完美,我们不需要创建 1957 个单独的手动测试,这将是一个费力且脆弱的替代方案!
我们应该注意,Groovy 内置了一些组合和排列功能。Groovy 默认情况下不包含排列中的空情况,但我们很容易添加它。以下是如何在 Groovy 中编写上述测试的方法,而无需任何额外的依赖项
@Test
void numberOfPermutations() {
var perms = (1..6).subsequences()*.permutations().sum() << []
assert perms.size() == 1957
}
我们将在后面看到更多关于 Chronicle 测试框架和 Groovy 内置功能的示例。
测试场景
为了获得更多背景信息,我们鼓励您阅读 原始帖子。我们将使用相同的两个场景,涉及测试列表操作序列,以确保这些列表以相同的方式运行。这两个场景(虽然我们主要关注第一个场景)是
-
我们将比较
LinkedList
和ArrayList
类,对这两个类执行一系列变异操作,例如clear
、add
和remove
,并检查我们是否得到相同的结果。 -
我们将扩展第一个场景,以涵盖更广泛的列表,包括
CopyOnWriteArrayList
、Stack
和Vector
。
使用 Chronicle 测试框架的场景 1
我们首先创建一个谓词来测试奇数,因为我们的一个操作需要它。然后,我们创建一个包含我们要在列表上执行的操作的列表。
final Predicate<Integer> ODD = n -> n % 2 == 1
final OPERATIONS = [
NamedConsumer.of(List::clear, "clear()"),
NamedConsumer.of(list -> list.add(1), "add(1)"),
NamedConsumer.of(list -> list.removeElement(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)")
]
这与之前博客中显示的 Java 版本非常相似,但有一个细微的变化。我们使用了 Groovy 的 removeElement
方法,它是 remove
的别名。
注意
|
Java 有两个重载的 remove 方法,一个用于从列表中移除第一个元素(如果找到),另一个用于移除特定索引处的元素。在处理整数列表时,有时(如原始博客所示)需要使用强制转换来消除这两种变体之间的歧义。Groovy 也使用相同的强制转换技巧,但还提供了 removeElement 和 remoteAt 别名作为消除歧义的替代选择。我们将在稍后看到 removeAt 的示例。 |
现在,我们可以定义我们的测试
@TestFactory
Stream<DynamicTest> validate() {
DynamicTest.stream(Combination.of(OPERATIONS)
.flatMap(Permutation::of),
FormatHelper::toString,
operations -> {
ArrayList first = []
LinkedList second = []
operations.forEach { op ->
op.accept(first)
op.accept(second)
}
assert first == second
})
}
这会为我们操作的所有排列生成测试用例。对于每个测试用例,我们检查在应用当前排列中的所有操作后,两个列表是否具有相同的內容。
如果您想知道我们在之前定义 OPERATIONS
时为什么要使用 NamedConsumer
,这是为了在使用各种支持 JUnit5 的测试运行器运行测试时支持友好的测试名称。以下是使用 Intellij IDEA 运行时显示的前 9 个中的 326 个测试
使用 Chronicle 测试框架的场景 2
对于这种情况,我们想要比较更多列表类型之间的结果。同样,我们可以手动创建上述测试的额外变体,以适应其他列表类型之间的比较,但为什么不生成这些变体,而不必进行额外的手动操作呢?
为此,我们创建了一个生成我们感兴趣的列表的工厂列表
final CONSTRUCTORS = [
ArrayList, LinkedList, CopyOnWriteArrayList, Stack, Vector
].collect(clazz -> clazz::new as Supplier)
现在,我们可以创建一个类似于原始博客中的测试,它对所有列表上的所有操作排列进行运行,然后检查每个列表组合,以确保生成的列表相等
@TestFactory
Stream<DynamicTest> validateMany() {
DynamicTest.stream(Combination.of(OPERATIONS)
.flatMap(Permutation::of),
FormatHelper::toString,
operations -> {
var lists = CONSTRUCTORS.stream()
.map(Supplier::get)
.toList()
operations.forEach(lists::forEach)
Combination.of(lists)
.filter(set -> set.size() == 2)
.map(ArrayList::new)
.forEach { p1, p2 -> assert p1 == p2 }
})
}
我们可以通过类似的测试来检查我们的不同列表组合是否正在被正确生成
@Test
void numberOfPairCombinations() {
assert Combination.of(CONSTRUCTORS)
.filter(l -> l.size() == 2)
.peek { println it*.get()*.class*.simpleName }
.count() == 10
}
我们可以看到有 10 对,类型如下
[ArrayList, LinkedList] [ArrayList, CopyOnWriteArrayList] [ArrayList, Stack] [ArrayList, Vector] [LinkedList, CopyOnWriteArrayList] [LinkedList, Stack] [LinkedList, Vector] [CopyOnWriteArrayList, Stack] [CopyOnWriteArrayList, Vector] [Stack, Vector]
在这一点上,原始博客继续警告在跨多个维度或情况计算排列时,潜在的指数级测试用例数量问题。我们很快就会回到这个问题,但让我们先看看使用原生 Groovy 对这两个场景进行类似的测试。
使用原生 Groovy 和 JUnit5 的场景 1
我们创建操作的列表
final OPERATIONS = [
List::clear,
{ list -> list.add(1) },
{ list -> list.removeElement(1) },
{ list -> list.addAll(Arrays.asList(2, 3, 4, 5)) },
{ list -> list.removeIf(ODD) }
]
现在,我们使用 Groovy 的 eachPermutation
方法遍历不同的排列
@Test
void validate() {
OPERATIONS.eachPermutation { opList ->
ArrayList first = []
LinkedList second = []
opList.each { op ->
op(first)
op(second)
}
assert first == second
}
}
使用原生 Groovy 和 JUnit5 的场景 2
使用与之前相同的 OPERATIONS
和 CONSTRUCTORS
定义,我们可以将测试编写如下
@Test
void validateMany() {
OPERATIONS.eachPermutation { opList ->
def pairs = CONSTRUCTORS*.get().subsequences().findAll { it.size() == 2 }
pairs.each { first, second ->
opList.each { op ->
op(first)
op(second)
}
assert first == second
}
}
}
我们可以像以前一样再次双重检查列表类型
@Test
void numberOfPairCombinations() {
assert (1..5).subsequences()
.findAll(l -> l.size() == 2)
.size() == 10
}
同样,有 10 对组合。
Groovy 版本不需要任何额外的依赖项,但有一个区别。缺少嵌套结果的漂亮格式化。Intellij 中的 JUnit5 运行将如下所示
我们无法深入到 validate
和 validateMany
测试中的不同测试子情况。让我们使用原生 Groovy 和 Spock 将该功能合并进来。我们只展示场景 1 的方法,但如果需要,可以使用相同的方法来处理场景 2。
使用数据驱动测试和 JUnit5 的场景 1
首先,我们将操作列表更改为一个映射,其键是我们之前在使用 NamedConsumer
时看到的名称
final OPERATIONS = [
'clear()' : List::clear,
'add(1)' : { list -> list.add(1) },
'remove(1)' : { list -> list.removeElement(1) },
'addAll(2,3,4,5)': { list -> list.addAll(Arrays.asList(2, 3, 4, 5)) },
'removeIf(ODD)' : { list -> list.removeIf(ODD) }
]
现在,我们将创建一个辅助方法来生成我们的排列,包括友好的名称和操作
Stream<Arguments> operationPermutations() {
OPERATIONS.entrySet().permutations().collect(e -> Arguments.of(e.key, e.value)).stream()
}
有了这些,我们可以将测试更改为使用 JUnit5 的数据驱动 ParameterizedTest
功能
@ParameterizedTest(name = "{index} {0}")
@MethodSource("operationPermutations")
void validate(List<String> names, List<Closure> operations) {
ArrayList first = []
LinkedList second = []
operations.each { op ->
op(first)
op(second)
}
assert first == second
}
输出如下
使用 Spock 的场景 1
我们还希望说明另一个有用的框架,即 Spock 测试框架,它也支持 数据驱动测试。
Spock 支持多种不同的测试风格。这里我们使用given、when、then 风格,以及用于数据驱动测试的where 子句
def "[#iterationIndex] #names"(List<String> names, List<Closure> operations) {
given:
ArrayList first = []
LinkedList second = []
when:
operations.each { op ->
op(first)
op(second)
}
then:
first == second
where:
entries << OPERATIONS.entrySet().permutations()
(names, operations) = entries.collect{ [it.key, it.value] }.transpose()
}
运行时,输出如下
现在,让我们来介绍一些额外的主题。
AllPairs
原始帖子中的“最终警告”是提醒我们要注意使用组合和排列时可能会出现的测试用例爆炸问题。
成对测试 的概念是一种旨在帮助限制这种测试用例爆炸的技术。它依赖于这样一个事实,即许多错误是在两个功能发生不良交互时出现的。如果我们有一个涉及五个功能的测试,那么我们可能不需要所有五个功能的所有组合。使用示例可能更容易理解。
让我们添加一些操作,然后将其分为三组:增长、缩减 和读取 操作。
final GROW_OPS = [
'add(1)': { list -> list.add(1) },
'addAll([2, 3, 4, 5])': { list -> list.addAll([2, 3, 4, 5]) },
'maybe add(1)': { list -> if (new Random().nextBoolean()) list.add(1) },
].entrySet().toList()
final SHRINK_OPS = [
'clear()': List::clear,
'remove(1)': { list -> list.removeElement(1) },
'removeIf(ODD)': { list -> list.removeIf(ODD) }
].entrySet().toList()
final READ_OPS = [
'isEmpty()': List::isEmpty,
'size()': List::size,
'contains(1)': { list -> list.contains(1) },
].entrySet().toList()
我们希望测试用例执行增长操作,然后执行缩减操作,最后执行读取操作。如果我们想要涵盖所有可能的组合,我们需要 27 个测试用例
assert [ADD_OPS, REMOVE_OPS, READ_OPS].combinations().size() == 27
许多语言都有大量的成对库。我们将使用 AllPairs4J 库用于 Java。
该库有一个构建器,我们可以在其中指定感兴趣的参数,然后它会生成成对组合。我们对每个组合执行与之前类似的测试
@Test
void validate() {
var allPairs = new AllPairs.AllPairsBuilder()
.withTestCombinationSize(2)
.withParameter(new Parameter("Add op", ADD_OPS))
.withParameter(new Parameter("Remove op", REMOVE_OPS))
.withParameter(new Parameter("Read op", READ_OPS))
.build()
allPairs.eachWithIndex { namedOps, index ->
print "$index: "
ArrayList first = []
LinkedList second = []
var log = []
namedOps.each{ k, v ->
log << "$k=$v.key"
var op = v.value
op(first)
op(second)
}
println log.join(', ')
assert first == second
}
}
我们使用 withTestCombinationSize(2)
来创建成对组合,但该库支持 n-wise(如果需要)。我们还使用了一个简单的自制日志,以便更容易地了解正在发生的事情,但如果需要,我们可以将其挂钩到我们之前在 JUnit5 和 Spock 中看到的数据驱动集成点。
运行此测试时,输出如下
1: Add op=add(1), Remove op=clear(), Read op=isEmpty() 2: Add op=maybe add(1), Remove op=remove(1), Read op=isEmpty() 3: Add op=addAll([2, 3, 4, 5]), Remove op=removeIf(ODD), Read op=isEmpty() 4: Add op=addAll([2, 3, 4, 5]), Remove op=remove(1), Read op=size() 5: Add op=maybe add(1), Remove op=clear(), Read op=size() 6: Add op=add(1), Remove op=removeIf(ODD), Read op=size() 7: Add op=add(1), Remove op=remove(1), Read op=contains(1) 8: Add op=maybe add(1), Remove op=removeIf(ODD), Read op=contains(1) 9: Add op=addAll([2, 3, 4, 5]), Remove op=clear(), Read op=contains(1)
您可以看到,只生成了 9 个测试,而不是进行全面测试所需的 27 个组合。要了解正在发生的事情,我们需要进一步检查输出。
如果我们只查看 add(1)
添加操作,我们会看到所有三个移除操作和所有三个读取操作都在测试中被涵盖
1: Add op=add(1), Remove op=clear(), Read op=isEmpty() 2: Add op=maybe add(1), Remove op=remove(1), Read op=isEmpty() 3: Add op=addAll([2, 3, 4, 5]), Remove op=removeIf(ODD), Read op=isEmpty() 4: Add op=addAll([2, 3, 4, 5]), Remove op=remove(1), Read op=size() 5: Add op=maybe add(1), Remove op=clear(), Read op=size() 6: Add op=add(1), Remove op=removeIf(ODD), Read op=size() 7: Add op=add(1), Remove op=remove(1), Read op=contains(1) 8: Add op=maybe add(1), Remove op=removeIf(ODD), Read op=contains(1) 9: Add op=addAll([2, 3, 4, 5]), Remove op=clear(), Read op=contains(1)
如果我们只查看 maybe add(1)
添加操作,我们会看到所有三个移除操作和所有三个读取操作都在被涵盖
1: Add op=add(1), Remove op=clear(), Read op=isEmpty() 2: Add op=maybe add(1), Remove op=remove(1), Read op=isEmpty() 3: Add op=addAll([2, 3, 4, 5]), Remove op=removeIf(ODD), Read op=isEmpty() 4: Add op=addAll([2, 3, 4, 5]), Remove op=remove(1), Read op=size() 5: Add op=maybe add(1), Remove op=clear(), Read op=size() 6: Add op=add(1), Remove op=removeIf(ODD), Read op=size() 7: Add op=add(1), Remove op=remove(1), Read op=contains(1) 8: Add op=maybe add(1), Remove op=removeIf(ODD), Read op=contains(1) 9: Add op=addAll([2, 3, 4, 5]), Remove op=clear(), Read op=contains(1)
如果我们只查看 addAll([2, 3, 4, 5])
添加操作,我们会再次看到所有三个移除操作和所有三个读取操作都在被涵盖
1: Add op=add(1), Remove op=clear(), Read op=isEmpty() 2: Add op=maybe add(1), Remove op=remove(1), Read op=isEmpty() 3: Add op=addAll([2, 3, 4, 5]), Remove op=removeIf(ODD), Read op=isEmpty() 4: Add op=addAll([2, 3, 4, 5]), Remove op=remove(1), Read op=size() 5: Add op=maybe add(1), Remove op=clear(), Read op=size() 6: Add op=add(1), Remove op=removeIf(ODD), Read op=size() 7: Add op=add(1), Remove op=remove(1), Read op=contains(1) 8: Add op=maybe add(1), Remove op=removeIf(ODD), Read op=contains(1) 9: Add op=addAll([2, 3, 4, 5]), Remove op=clear(), Read op=contains(1)
您可能会想,通过将测试数量从 27 个减少到 9 个,我们是否降低了发现错误的机会?如果错误是由于两个功能发生不良交互造成的,那么不会,我们仍然涵盖了所有情况。但这并不总是正确的,因为难以察觉的错误可能是由于多个功能发生交互造成的。因此,该库支持 n-wise 测试。从本质上讲,这种技术可以让您在组合测试爆炸和发现更多难以察觉的错误的机会之间取得平衡。
让我们做一个快速交叉检查,以增强对 9 个测试用例的信心。
首先,我们将调整测试以捕获异常,并在此时打印出我们自制的日志。这只是我们处理此类异常的一种方法
namedOps.each{ k, v ->
try {
log << "$k=$v.key"
var op = v.value
op(first)
op(second)
} catch(ex) {
println 'Failed on last op of: ' + log.join(', ')
throw ex
}
}
现在,让我们故意引入一个错误。我们将用一个尝试移除索引 0 处的元素(假设至少有一个元素)的操作替换我们的第二个缩减操作
final SHRINK_OPS = [
'clear()': List::clear,
// 'remove(1)': { list -> list.removeElement(1) }, // (1)
'removeAt(0)': { list -> list.removeAt(0) }, // (2)
'removeIf(ODD)': { list -> list.removeIf(ODD) }
].entrySet().toList()
-
注释掉此操作
-
添加此有问题的操作
现在,运行测试时,我们会看到
> Task :test FAILED 0: Grow op=add(1), Shrink op=clear(), Read op=isEmpty() 1: Grow op=addAll([2, 3, 4, 5]), Shrink op=removeAt(0), Read op=isEmpty() 2: Grow op=maybe add(1), Shrink op=removeIf(ODD), Read op=isEmpty() 3: Failed on last op of: Grow op=maybe add(1), Shrink op=removeAt(0)
这里我们可以看到情况 0、1 和 2 成功了。对于情况 3,增长操作(随机地有一半时间添加一个元素)一定没有添加任何元素,随后尝试移除第一个元素失败了。因此,即使我们的测试用例数量很少,也检测到了这个“错误”。
Jqwik
属性测试工具也试图比手动测试更容易(和维护)地进行更多测试,但它们并没有专注于完全彻底的测试。相反,它们专注于生成随机测试输入,然后检查某些属性是否成立。
支持有状态属性测试的框架还允许您生成随机命令,我们可以对有状态系统发出这些命令,然后检查某些属性是否成立。
我们将以这种方式使用 jqwik 的 有状态测试 功能。
我们从与之前相同的操作(和友好名称)映射开始
final OPERATIONS = [
'clear()' : List::clear,
'add(1)' : { list -> list.add(1) },
'remove(1)' : { list -> list.removeElement(1) },
'addAll(2,3,4,5)': { list -> list.addAll(Arrays.asList(2, 3, 4, 5)) },
'removeIf(ODD)' : { list -> list.removeIf(ODD) }
].entrySet().toList()
jqwik 中的状态测试功能具有操作链的概念,描述了如何转换有状态对象。在我们的例子中,我们随机选择一个操作,然后将选定的操作应用于两个列表,并检查这两个列表是否包含相同的值。
class MutateAction implements Action.Independent<Tuple2<List, List>> {
Arbitrary<Transformer<Tuple2<List, List>>> transformer() {
Arbitraries.of(OPERATIONS).map(operation ->
Transformer.mutate(operation.key) { list1, list2 ->
var op = operation.value
op(list1)
op(list2)
assert list1 == list2
})
}
}
我们现在指定要在操作链中使用最多 6 个操作,并且要从一个 ArrayList 和一个 LinkedList 开始,它们都包含一个元素,即 Integer 1。
@Provide
Arbitrary<ActionChain> myListActions() {
ActionChain.startWith{ Tuple2.tuple([1] as ArrayList, [1] as LinkedList) }
.withAction(new MutateAction())
.withMaxTransformations(6)
}
@Provide
注解表明该方法可以用来为需要操作链的测试提供输入。
最后,我们添加我们的测试。对于 jqwik,这是使用 @Property
注解完成的。
@Property(seed='100001')
void confirmSimilarListBehavior(@ForAll("myListActions") ActionChain chain) {
chain.run()
}
seed 注解属性是可选的,可用于获取可重复的测试。
当我们运行此测试时,我们会看到 jqwik 生成了 1000 个不同的操作序列,并且它们都通过了。
|-----------------------jqwik----------------------- tries = 1000 | # of calls to property checks = 1000 | # of not rejected calls generation = RANDOMIZED | parameters are randomly generated after-failure = SAMPLE_FIRST | try previously failed sample, then previous seed when-fixed-seed = ALLOW | fixing the random seed is allowed edge-cases#mode = MIXIN | edge cases are mixed in edge-cases#total = 0 | # of all combined edge cases edge-cases#tried = 0 | # of edge cases tried in current run seed = 100001 | random seed to reproduce generated values
和以前一样,我们可以故意破坏代码,以说服自己测试正在正常工作。让我们重新引入我们之前在全对测试中使用过的有问题的 removeAt
操作。
final OPERATIONS = [
'clear()' : List::clear,
'add(1)' : { list -> list.add(1) },
// 'remove(1)' : { list -> list.removeElement(1) }, // (1)
'removeAt(0)' : { list -> list.removeAt(0) }, // (2)
'addAll(2,3,4,5)': { list -> list.addAll(Arrays.asList(2, 3, 4, 5)) },
'removeIf(ODD)' : { list -> list.removeIf(ODD) }
-
注释掉
-
添加了可能导致故障的操作
当我们重新运行测试时,我们会看到
ListDemoDataDrivenJqwikTest:confirmSimilarListBehavior = org.opentest4j.AssertionFailedError: Run failed after the following actions: [ clear() removeAt(0) ] final state: [[], []] Index 0 out of bounds for length 0 |-----------------------jqwik----------------------- tries = 4 | # of calls to property checks = 4 | # of not rejected calls generation = RANDOMIZED | parameters are randomly generated after-failure = SAMPLE_FIRST | try previously failed sample, then previous seed when-fixed-seed = ALLOW | fixing the random seed is allowed edge-cases#mode = MIXIN | edge cases are mixed in edge-cases#total = 0 | # of all combined edge cases edge-cases#tried = 0 | # of edge cases tried in current run seed = 100001 | random seed to reproduce generated values ... Original Error -------------- org.opentest4j.AssertionFailedError: Run failed after the following actions: [ addAll(2,3,4,5) add(1) clear() removeAt(0) ] final state: [[], []] Index 0 out of bounds for length 0
输出中有一些内容需要解释
-
它生成了一个展示错误的“缩减”序列,包括
clear()
和removeAt(0)
操作。这是预期的错误。 -
它在第 4 次检查期间失败之前,成功运行了 3 个其他随机序列。
-
缩减之前的生成序列是
addAll(2,3,4,5)
、add(1)
、clear()
和removeAt(0)
。
从 Java 中使用
您还可以使用 Groovy 的排列和组合功能,如下面的测试所示。
@Test // Java
public void combinations() {
String[] letters = {"A", "B"};
Integer[] numbers = {1, 2};
Object[] collections = {letters, numbers};
var expected = List.of(
List.of("A", 1),
List.of("B", 1),
List.of("A", 2),
List.of("B", 2)
);
var combos = GroovyCollections.combinations(collections);
assertEquals(expected, combos);
}
@Test
public void subsequences() {
var numbers = List.of(1, 2, 3);
var expected = Set.of(
List.of(1), List.of(2), List.of(3),
List.of(1, 2), List.of(1, 3), List.of(2, 3),
List.of(1, 2, 3)
);
var result = GroovyCollections.subsequences(numbers);
assertEquals(expected, result);
}
@Test
public void permutations() {
var numbers = List.of(1, 2, 3);
var gen = new PermutationGenerator<>(numbers);
var result = new HashSet<>();
while (gen.hasNext()) {
List<Integer> next = gen.next();
result.add(next);
}
var expected = Set.of(
List.of(1, 2, 3), List.of(1, 3, 2),
List.of(2, 1, 3), List.of(2, 3, 1),
List.of(3, 1, 2), List.of(3, 2, 1)
);
assertEquals(expected, result);
}