使用 Groovy、Spock、JUnit5、Jacoco、Jqwik 和 Pitest 测试您的 Java 代码

作者: Paul King
发布日期: 2022-07-15 08:26AM


spock logo 本博客文章介绍了 Groovy 社区中常见的场景,即项目使用 Java 编写生产代码,而使用 Groovy 编写测试代码。对于 Java 开发团队来说,这是一个低风险的方式来尝试和熟悉 Groovy。我们将使用 Spock 测试框架 编写初始测试,稍后我们将使用 JUnit5 以及 jqwik 测试。如果您切换到 Groovy,通常可以使用您喜欢的 Java 测试库。

被测系统

为了说明目的,我们将测试一个 Java 数学工具函数 sumBiggestPair。给定三个数字,它会找到最大的两个数字,然后将它们加起来。此代码的初始尝试可能如下所示

public class MathUtil {                       // Java

    public static int sumBiggestPair(int a, int b, int c) {
        int op1 = a;
        int op2 = b;
        if (c > a) {
            op1 = c;
        } else if (c > b) {
            op2 = c;
        }
        return op1 + op2;
    }

    private MathUtil(){}
}

使用 Spock 测试

初始测试可能如下所示

class MathUtilSpec extends Specification {
    def "sum of two biggest numbers"() {
        expect:
        MathUtil.sumBiggestPair(2, 5, 3) == 8
    }
}

当我们运行此测试时,所有测试都通过了

MathUtilSpec test result

但是,如果我们查看使用 Jacoco 生成的覆盖率报告,我们会发现我们的测试没有覆盖所有代码行

MathUtilSpec coverage report

我们将切换到使用 Spock 的数据驱动功能,并包含一个额外的测试用例

def "sum of two biggest numbers"(int a, int b, int c, int d) {
    expect:
    MathUtil.sumBiggestPair(a, b, c) == d

    where:
    a | b | c | d
    2 | 5 | 3 | 8
    5 | 2 | 3 | 8
}

我们可以再次检查我们的覆盖率

MathUtilSpec coverage report

这有点好。我们现在有 100% 的行覆盖率,但没有 100% 的分支覆盖率。让我们添加一个额外的测试用例

def "sum of two biggest numbers"(int a, int b, int c, int d) {
    expect:
    MathUtil.sumBiggestPair(a, b, c) == d

    where:
    a | b | c | d
    2 | 5 | 3 | 8
    5 | 2 | 3 | 8
    5 | 4 | 1 | 9
}

现在我们可以看到,我们已经达到了 100% 的行覆盖率和 100% 的分支覆盖率

MathUtilSpec coverage report

此时,我们可能对我们的代码非常有信心,并准备将其发布到生产环境中。在我们这样做之前,我们将添加一个额外的测试用例

def "sum of two biggest numbers"(int a, int b, int c, int d) {
    expect:
    MathUtil.sumBiggestPair(a, b, c) == d

    where:
    a | b | c | d
    2 | 5 | 3 | 8
    5 | 2 | 3 | 8
    5 | 4 | 1 | 9
    3 | 2 | 6 | 9
}

当我们重新运行我们的测试时,我们发现最后一个测试用例失败了!

MathUtilSpec test result

检查测试用例后,我们确实可以看到我们的算法存在缺陷。基本上,else 逻辑无法处理 c 大于 ab 的情况!

MathUtilSpec failed assertion

我们屈服于对 100% 覆盖率的错误预期。

Imperfect puzzle

好消息是我们能解决这个问题。这是一个更新后的算法

public static int sumBiggestPair(int a, int b, int c) {                // Java
    int op1 = a;
    int op2 = b;
    if (c > Math.min(a, b)) {
        op1 = c;
        op2 = Math.max(a, b);
    }
    return op1 + op2;
}

使用此新算法,所有 4 个测试用例现在都通过了,我们再次获得了 100% 的行覆盖率和分支覆盖率。

> Task :SumBiggestPairPitest:test
 Test sum of two biggest numbers [Tests: 4/4/0/0] [Time: 0.317 s]
 Test util.MathUtilSpec [Tests: 4/4/0/0] [Time: 0.320 s]
 Test Gradle Test Run :SumBiggestPairPitest:test [Tests: 4/4/0/0]

但我们之前不是已经做到了吗?我们如何才能确定是否还有一些额外的测试用例可能会揭示我们算法中的另一个缺陷?我们可以继续编写更多测试用例,但我们将研究两种可以提供帮助的其他技术。

使用 Pitest 进行变异测试

一种有趣但并不广泛使用的技术是变异测试。它可能值得更广泛地使用。它可以测试测试套件的质量,但缺点是它有时非常资源密集。它会修改(变异)生产代码并重新运行您的测试套件。如果您的测试套件在修改后的代码下仍然通过,则可能表明您的测试套件缺少足够的覆盖率。早些时候,我们的算法有一个缺陷,我们的测试套件最初没有发现它。您可以将变异测试视为添加一个故意的缺陷,并查看您的测试套件是否足够好以检测该缺陷。

如果您是测试驱动开发 (TDD) 的粉丝,它宣扬一个规则,即除非失败的测试迫使添加该行代码,否则不应添加任何一行生产代码。一个推论是,如果您以任何有意义的方式更改了一行生产代码,那么某些测试应该失败。

因此,让我们看看变异测试对我们最初有缺陷的算法说了些什么。我们将使用 Pitest(也称为 PIT)。我们将回到我们的初始算法以及我们错误地认为我们有 100% 覆盖率的地方。当我们运行 Pitest 时,我们得到以下结果

Pitest coverage report summary

查看代码,我们会发现

Pitest coverage report

输出包含一些统计信息

======================================================================
- Statistics
======================================================================
>> Line Coverage: 7/8 (88%)
>> Generated 6 mutations Killed 4 (67%)
>> Mutations with no coverage 0. Test strength 67%
>> Ran 26 tests (4.33 tests per mutation)

这告诉我们什么?Pitest 以您可能期望它会破坏代码的方式变异了我们的代码,但我们的测试套件通过(幸存)了几次。这意味着两种情况之一。要么我们的算法有多种有效实现,Pitest 找到了其中一种等效的解决方案,要么我们的测试套件缺少一些关键的测试用例。在我们的例子中,我们知道测试套件是不充分的。

让我们再次运行它,但这次使用我们所有的测试和更正后的算法。

Pitest coverage report

运行测试时的输出也略有变化

======================================================================
- Statistics
======================================================================
>> Line Coverage: 6/7 (86%)
>> Generated 4 mutations Killed 3 (75%)
>> Mutations with no coverage 0. Test strength 75%
>> Ran 25 tests (6.25 tests per mutation)

我们来自 Pitest 的警告减少了,但没有完全消失,我们的测试强度增加了,但仍然不是 100%。这确实意味着我们比以前处于更好的状态。但我们应该担心吗?

事实证明,在这种情况下,我们不必担心(太多)。例如,我们正在测试的函数的另一个等效算法是将条件替换为 c >= Math.min(a, b)。请注意大于等于运算符,而不是仅仅大于。对于此算法,当 c 等于 ab 时,将走不同的路径,但最终结果将相同。因此,那将是一个无关紧要或等效的变异。在这种情况下,可能没有我们可以编写的额外测试用例来让 Pitest 满意。在使用此技术时,我们必须意识到这种可能的结局。

最后,让我们看看运行 Spock、Jacoco 和 Pitest 的构建文件

plugins {
    id 'info.solidsoft.pitest' version '1.7.4'
}
apply plugin: 'groovy'

repositories {
    mavenCentral()
}

dependencies {
    implementation "org.apache.groovy:groovy-test-junit5:4.0.3"
    testImplementation("org.spockframework:spock-core:2.2-M3-groovy-4.0") {
        transitive = false
    }
}

pitest {
    junit5PluginVersion = '1.0.0'
    pitestVersion = '1.9.2'
    timestampedReports = false
    targetClasses = ['util.*']
}

tasks.named('test') {
    useJUnitPlatform()
}

敏锐的读者可能会注意到一些微妙的提示,这些提示表明最新的 Spock 版本是在 JUnit 5 平台之上运行的。

使用基于属性的测试

基于属性的测试是另一种可能值得更多关注的技术。在这里,我们将使用 jqwik,它在 JUnit5 之上运行,但您可能还想考虑使用 Genesis,它提供随机生成器,尤其针对 Spock。

之前,我们查看了编写更多测试以使我们的覆盖率更强大。基于属性的测试通常会导致编写更少测试。相反,我们自动生成许多随机测试,并查看某些属性是否成立。

以前,我们输入了输入和预期输出。对于基于属性的测试,输入通常是随机生成的值,我们不知道输出。因此,我们将不会直接针对某些已知输出进行测试,而是会检查答案的各种属性。

例如,这是一个我们可以使用的测试

@Property
void "result should be bigger than any individual and smaller than sum of all"(
        @ForAll @IntRange(min = 0, max = 1000) Integer a,
        @ForAll @IntRange(min = 0, max = 1000) Integer b,
        @ForAll @IntRange(min = 0, max = 1000) Integer c) {
    def result = sumBiggestPair(a, b, c)
    assert [a, b, c].every { individual -> result >= individual }
    assert result <= a + b + c
}

@ForAll 注释表明 jqwik 将在哪些位置插入随机值。@IntRange 注释表明我们希望随机值在 0 到 1000 之间。

在这里,我们正在检查(至少对于较小的正数)添加两个最大数字应该大于或等于任何单个数字,并且应该小于或等于所有三个数字的总和。这些是必要的,但不足以确保我们的系统正常工作。

当我们运行它时,我们在日志中看到以下输出

                              |--------------------jqwik--------------------
tries = 1000                  | # of calls to property
checks = 1000                 | # of not rejected calls
generation = RANDOMIZED       | parameters are randomly generated
after-failure = PREVIOUS_SEED | use the previous seed
when-fixed-seed = ALLOW       | fixing the random seed is allowed
edge-cases#mode = MIXIN       | edge cases are mixed in
edge-cases#total = 125        | # of all combined edge cases
edge-cases#tried = 117        | # of edge cases tried in current run
seed = -311315135281003183    | random seed to reproduce generated values

因此,我们写了 1 个测试,并执行了 1000 个测试用例。运行的测试数量是可配置的。我们这里不会详细介绍。乍一看,这似乎很棒。但是,事实证明,这个特定的属性在发现错误方面并没有那么有鉴别力。此测试针对我们最初有缺陷的算法以及已修复的算法都通过了。让我们尝试另一个属性

@Property
void "sum of any pair should not be greater than result"(
        @ForAll @IntRange(min = 0, max = 1000) Integer a,
        @ForAll @IntRange(min = 0, max = 1000) Integer b,
        @ForAll @IntRange(min = 0, max = 1000) Integer c) {
    def result = sumBiggestPair(a, b, c)
    assert [a + b, b + c, c + a].every { sumOfPair -> result >= sumOfPair }
}

如果我们计算最大的对,那么它肯定必须大于或等于任何任意对。在我们的有缺陷的算法上尝试这个会得到

org.codehaus.groovy.runtime.powerassert.PowerAssertionError:
    assert [a + b, b + c, c + a].every { sumOfPair -> result >= sumOfPair }
            | | |  | | |  | | |  |
            1 1 0  0 2 2  2 3 1  false
                              |--------------------jqwik--------------------
tries = 12                    | # of calls to property
checks = 12                   | # of not rejected calls
generation = RANDOMIZED       | parameters are randomly generated
after-failure = PREVIOUS_SEED | use the previous seed
when-fixed-seed = ALLOW       | fixing the random seed is allowed
edge-cases#mode = MIXIN       | edge cases are mixed in
edge-cases#total = 125        | # of all combined edge cases
edge-cases#tried = 2          | # of edge cases tried in current run
seed = 4830696361996686755    | random seed to reproduce generated values

Shrunk Sample (6 steps)
-----------------------
  arg0: 1
  arg1: 0
  arg2: 2

Original Sample
---------------
  arg0: 247
  arg1: 32
  arg2: 267

  Original Error
  --------------
  org.codehaus.groovy.runtime.powerassert.PowerAssertionError:
    assert [a + b, b + c, c + a].every { sumOfPair -> result >= sumOfPair }
            | | |  | | |  | | |  |
            | | 32 32| 267| | |  false
            | 279    299  | | 247
            247           | 514
                          267

它不仅找到一个突出了缺陷的情况,而且还将其缩小到一个非常简单的示例。在我们已修复的算法上,1000 个测试通过了!

之前的属性可以稍微重构一下,不仅计算所有三对,然后找到这些对中的最大值。这在一定程度上简化了条件

@Property
void "result should be the same as alternative oracle implementation"(
        @ForAll @IntRange(min = 0, max = 1000) Integer a,
        @ForAll @IntRange(min = 0, max = 1000) Integer b,
        @ForAll @IntRange(min = 0, max = 1000) Integer c) {
    assert sumBiggestPair(a, b, c) == [a+b, a+c, b+c].max()
}

这种使用替代实现的方法称为测试预言机。替代实现可能效率较低,因此不适合生产代码,但适合测试。在改进或替换某些软件时,预言机可能是现有系统。在我们的已修复算法上运行时,我们再次有 1000 个测试用例通过。

让我们更进一步,从整数中删除 @IntRange 边界

@Property
void "result should be the same as alternative oracle implementation"(@ForAll Integer a, @ForAll Integer b, @ForAll Integer c) {
    assert sumBiggestPair(a, b, c) == [a+b, a+c, b+c].max()
}

当我们现在运行测试时,我们可能会感到惊讶

  org.codehaus.groovy.runtime.powerassert.PowerAssertionError:
    assert sumBiggestPair(a, b, c) == [a+b, a+c, b+c].max()
           |              |  |  |  |   |||  |||  |||  |
           -2147483648    0  1  |  |   0|1  0||  1||  2147483647
                                |  |    1    ||   |2147483647
                                |  false     ||   -2147483648
                                2147483647   |2147483647
                                             2147483647
Shrunk Sample (13 steps)
------------------------
  arg0: 0
  arg1: 1
  arg2: 2147483647

它失败了!这是我们的算法中的另一个错误吗?可能?但它也可能是在我们的属性测试中存在错误。有必要进一步调查。

事实证明,我们的算法在尝试将 1 加到 Integer.MAX_VALUE 时会出现整数溢出。我们的测试部分会遇到同样的问题,但当我们调用 max() 时,负值将被丢弃。对于这种情况,没有始终正确的答案。我们回到客户那里,并检查实际要求。在这种情况下,让我们假设客户对溢出很满意 - 因为这是在 Java 中手动执行此操作时会发生的。有了这些知识,我们应该修复我们的测试,至少在发生溢出时正确通过。

我们有多种选择来解决这个问题。我们之前已经看到,我们可以使用 @IntRange。这是“避免”问题的一种方法,我们还有几种类似的方法可以做到这一点。我们可以使用范围更小的数据类型,例如 Short

@Property
void checkShort(@ForAll Short a, @ForAll Short b, @ForAll Short c) {
    assert sumBiggestPair(a, b, c) == [a+b, a+c, b+c].max()
}

或者我们可以使用自定义的提供程序方法

@Property
void checkIntegerConstrainedProvider(@ForAll('halfMax') Integer a,
                                     @ForAll('halfMax') Integer b,
                                     @ForAll('halfMax') Integer c) {
    assert sumBiggestPair(a, b, c) == [a+b, a+c, b+c].max()
}

@Provide
Arbitrary<Integer> halfMax() {
    int halfMax = Integer.MAX_VALUE >> 1
    return Arbitraries.integers().between(-halfMax, halfMax)
}

但是,与其避免问题,不如更改我们的测试,使其允许 sumBiggestPair 中可能出现的溢出,但不会在其自身的溢出中加剧问题。例如,我们可以在测试中使用 Long 来进行计算

@Property
void checkIntegerWithLongCalculations(@ForAll Integer a, @ForAll Integer b, @ForAll Integer c) {
    def (al, bl, cl) = [a, b, c]*.toLong()
    assert sumBiggestPair(a, b, c) == [al+bl, al+cl, bl+cl].max().toInteger()
}

最后,让我们再次看看我们的 Gradle 构建文件

apply plugin: 'groovy'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation project(':SumBiggestPair')
    testImplementation "org.apache.groovy:groovy-test-junit5:4.0.3"
    testImplementation "net.jqwik:jqwik:1.6.5"
}

test {
    useJUnitPlatform {
        includeEngines 'jqwik'
    }
}

更多信息

本博客文章中的示例摘自以下仓库
https://github.com/paulk-asert/property-based-testing

使用的库版本
Gradle 7.5、Groovy 4.0.3、jqwik 1.6.5、pitest 1.9.2、Spock 2.2-M3-groovy-4.0、Jacoco 0.8.8。
使用 JDK 8、11、17、18 进行测试。

关于这里介绍的技术,有很多网站提供宝贵的信息,也有一些很棒的书籍。关于 Spock 的书籍包括:Spock: Up and RunningJava Testing with SpockSpocklight Notebook。关于 Groovy 的书籍包括:Groovy in ActionLearning Groovy 3。如果您想了解有关将 Java 和 Groovy 结合使用的通用信息,请考虑阅读 Making Java Groovy。在 Practical Unit Testing With Testng And Mockito 中有一节介绍变异测试。最近关于属性测试的书籍是针对 Erlang 和 Elixir 语言 的。

结论

我们已经探讨了使用 Groovy 和 Spock 以及一些额外的工具(如 Jacoco、jqwik 和 Pitest)来测试 Java 代码。通常情况下,使用 Groovy 来测试 Java 是一种直截了当的体验。Groovy 也适合编写测试 DSL,允许非硬核程序员编写非常简单的测试;但这将是另一个博客主题!