在 JVM 上进行测试是一个内容丰富的课题。有许多不同的测试库和框架,以及许多不同类型的测试。所有这些都需要成为构建的一部分,无论它们是频繁执行还是不频繁执行。本章专门解释 Gradle 如何处理构建之间和构建内部的不同需求,并详细介绍它如何与两个最常见的测试框架集成:JUnitTestNG

它解释了

但首先,让我们看看 Gradle 中 JVM 测试的基础知识。

通过孵化中的 JVM Test Suite 插件,可以获得用于建模测试执行阶段的新配置 DSL。

基本概念

所有 JVM 测试都围绕一个任务类型展开:Test。它使用任何受支持的测试库(JUnit、JUnit Platform 或 TestNG)运行一组测试用例,并整理结果。然后,您可以通过 TestReport 任务类型的实例将这些结果转换为报告。

为了运行,Test 任务类型只需要两条信息

当您使用 JVM 语言插件(例如 Java 插件)时,您将自动获得以下内容

  • 用于单元测试的专用 test 源集

  • 一个类型为 Testtest 任务,用于运行这些单元测试

JVM 语言插件使用源集配置任务,使其具有适当的执行类路径和包含已编译测试类的目录。此外,它们还将 test 任务附加到 check 生命周期任务

还值得记住的是,test 源集会自动创建相应的依赖配置——其中最有用的是 testImplementationtestRuntimeOnly——插件将这些配置绑定到 test 任务的类路径中。

在大多数情况下,您只需要配置适当的编译和运行时依赖项,并向 test 任务添加任何必要的配置。以下示例显示了一个使用 JUnit Platform 并将测试 JVM 的最大堆大小更改为 1 GB 的简单设置

build.gradle.kts
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.7.1")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.named<Test>("test") {
    useJUnitPlatform()

    maxHeapSize = "1G"

    testLogging {
        events("passed")
    }
}
build.gradle
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test', Test) {
    useJUnitPlatform()

    maxHeapSize = '1G'

    testLogging {
        events "passed"
    }
}

Test 任务有许多通用配置选项以及几个特定于框架的选项,您可以在 JUnitOptionsJUnitPlatformOptionsTestNGOptions 中找到详细描述。我们将在本章的其余部分介绍其中大部分选项。

如果您想使用自己的测试类集合设置自己的 Test 任务,那么最简单的方法是创建自己的源集和 Test 任务实例,如 配置集成测试 中所示。

测试执行

Gradle 在一个单独的(“forked”)JVM 中执行测试,与主构建过程隔离。这可以防止类路径污染和构建过程的内存消耗过大。它还允许您使用与构建使用的 JVM 参数不同的 JVM 参数来运行测试。

您可以通过 Test 任务上的几个属性控制测试过程的启动方式,包括以下内容

maxParallelForks — 默认值:1

您可以通过将此属性设置为大于 1 的值来并行运行测试。这可以使您的测试套件更快地完成,特别是当您在多核 CPU 上运行它们时。使用并行测试执行时,请确保您的测试彼此之间适当隔离。与文件系统交互的测试特别容易发生冲突,从而导致间歇性测试失败。

您的测试可以通过使用 org.gradle.test.worker 属性的值来区分并行测试过程,该值对于每个过程都是唯一的。您可以将此用于任何您想要的目的,但它特别适用于文件名和其他资源标识符,以防止我们刚才提到的那种冲突。

forkEvery — 默认值:0(无最大值)

此属性指定 Gradle 在处理测试进程之前应运行的最大测试类数,之后该进程将被处置并创建新的进程。这主要用作管理内存泄漏测试或在测试之间无法清除或重置静态状态的框架的一种方法。

警告:低值(非 0)可能会严重损害测试性能

ignoreFailures — 默认值:false

如果此属性为 true,Gradle 将在测试完成后继续项目的构建,即使其中一些测试失败。请注意,默认情况下,Test 任务始终执行它检测到的每个测试,无论此设置如何。

failFast —(从 Gradle 4.6 开始)默认值:false

如果您希望构建在某个测试失败后立即失败并结束,请将其设置为 true。当您有一个长时间运行的测试套件时,这可以节省大量时间,并且在持续集成服务器上运行构建时特别有用。当构建在所有测试运行之前失败时,测试报告只包含已完成测试(无论成功与否)的结果。

您还可以通过使用 --fail-fast 命令行选项启用此行为,或分别使用 --no-fail-fast 禁用此行为。

testLogging — 默认值:未设置

此属性表示一组选项,用于控制哪些测试事件被记录以及记录级别。您还可以通过此属性配置其他日志记录行为。有关更多详细信息,请参阅 TestLoggingContainer

dryRun — 默认值:false

如果此属性为 true,Gradle 将模拟测试的执行,而不会实际运行它们。这仍然会生成报告,允许检查选择了哪些测试。这可用于验证您的测试过滤配置是否正确,而无需实际运行测试。

您还可以通过使用 --test-dry-run 命令行选项启用此行为,或分别使用 --no-test-dry-run 禁用此行为。

有关所有可用配置选项的详细信息,请参阅 Test

测试过滤

运行测试套件的子集是常见的需求,例如在修复错误或开发新测试用例时。Gradle 提供了两种机制来实现此目的

  • 过滤(首选选项)

  • 测试包含/排除

过滤取代了包含/排除机制,但您仍然可能会在实际项目中遇到后者。

使用 Gradle 的测试过滤,您可以根据以下条件选择要运行的测试

  • 完全限定类名或完全限定方法名,例如 org.gradle.SomeTestorg.gradle.SomeTest.someMethod

  • 如果模式以大写字母开头,则为简单类名或方法名,例如 SomeTestSomeTest.someMethod(从 Gradle 4.7 开始)

  • '*' 通配符匹配

您可以在构建脚本中或通过 --tests 命令行选项启用过滤。以下是每次构建运行时都会应用的一些过滤器的示例

build.gradle.kts
tasks.test {
    filter {
        //include specific method in any of the tests
        includeTestsMatching("*UiCheck")

        //include all tests from package
        includeTestsMatching("org.gradle.internal.*")

        //include all integration tests
        includeTestsMatching("*IntegTest")
    }
}
build.gradle
test {
    filter {
        //include specific method in any of the tests
        includeTestsMatching "*UiCheck"

        //include all tests from package
        includeTestsMatching "org.gradle.internal.*"

        //include all integration tests
        includeTestsMatching "*IntegTest"
    }
}

有关在构建脚本中声明过滤器的更多详细信息和示例,请参阅 TestFilter 参考。

命令行选项对于执行单个测试方法特别有用。当您使用 --tests 时,请注意构建脚本中声明的包含仍然有效。也可以提供多个 --tests 选项,所有这些模式都将生效。以下部分有几个使用命令行选项的示例。

并非所有测试框架都与过滤配合良好。一些高级的、合成的测试可能无法完全兼容。但是,绝大多数测试和用例都与 Gradle 的过滤机制完美配合。

以下两节着眼于简单类名/方法名和完全限定名的特定情况。

简单名称模式

自 4.7 版起,Gradle 将以大写字母开头的模式视为简单类名或类名 + 方法名。例如,以下命令行运行 SomeTestClass 测试用例中的所有测试或仅一个测试,无论它位于哪个包中

# Executes all tests in SomeTestClass
gradle test --tests SomeTestClass

# Executes a single specified test in SomeTestClass
gradle test --tests SomeTestClass.someSpecificMethod

gradle test --tests SomeTestClass.*someMethod*

完全限定名称模式

在 4.7 之前或如果模式不以大写字母开头,Gradle 会将模式视为完全限定。因此,如果您想使用测试类名而不考虑其包,则可以使用 --tests *.SomeTestClass。以下是一些更多示例

# specific class
gradle test --tests org.gradle.SomeTestClass

# specific class and method
gradle test --tests org.gradle.SomeTestClass.someSpecificMethod

# method name containing spaces
gradle test --tests "org.gradle.SomeTestClass.some method containing spaces"

# all classes at specific package (recursively)
gradle test --tests 'all.in.specific.package*'

# specific method at specific package (recursively)
gradle test --tests 'all.in.specific.package*.someSpecificMethod'

gradle test --tests '*IntegTest'

gradle test --tests '*IntegTest*ui*'

gradle test --tests '*ParameterizedTest.foo*'

# the second iteration of a parameterized test
gradle test --tests '*ParameterizedTest.*[2]'

请注意,通配符“*”对“.”包分隔符没有特殊理解。它纯粹是基于文本的。因此 --tests *.SomeTestClass 将匹配任何包,无论其“深度”如何。

您还可以将命令行中定义的过滤器与持续构建结合使用,以便在生产或测试源文件每次更改后立即重新执行测试子集。以下命令在每次更改触发测试运行时执行“com.mypackage.foo”包或子包中的所有测试

gradle test --continuous --tests "com.mypackage.foo.*"

测试报告

Test 任务默认生成以下结果

  • HTML 测试报告

  • 与 Ant JUnit 报告任务兼容格式的 XML 测试结果——许多其他工具(例如 CI 服务器)都支持该格式

  • Test 任务用于生成其他格式的结果的高效二进制格式

在大多数情况下,您将使用标准 HTML 报告,该报告自动包含所有 Test 任务的结果,即使是您自己明确添加到构建中的任务。例如,如果您为集成测试添加了 Test 任务,则如果两个任务都运行,报告将包含单元测试和集成测试的结果。

要汇总多个子项目的测试结果,请参阅 测试报告聚合插件

与许多测试配置选项不同,有几个项目级约定属性会影响测试报告。例如,您可以像这样更改测试结果和报告的目标位置

build.gradle.kts
reporting.baseDirectory = file("my-reports")
java.testResultsDir = layout.buildDirectory.dir("my-test-results")

tasks.register("showDirs") {
    val settingsDir = project.layout.settingsDirectory.asFile
    val reportsDir = project.reporting.baseDirectory
    val testResultsDir = project.java.testResultsDir

    doLast {
        logger.quiet(settingsDir.toPath().relativize(reportsDir.get().asFile.toPath()).toString())
        logger.quiet(settingsDir.toPath().relativize(testResultsDir.get().asFile.toPath()).toString())
    }
}
build.gradle
reporting.baseDirectory = file("my-reports")
java.testResultsDir = layout.buildDirectory.dir("my-test-results")

tasks.register('showDirs') {
    def settingsDir = project.layout.settingsDirectory.asFile
    def reportsDir = project.reporting.baseDirectory
    def testResultsDir = project.java.testResultsDir

    doLast {
        logger.quiet(settingsDir.toPath().relativize(reportsDir.get().asFile.toPath()).toString())
        logger.quiet(settingsDir.toPath().relativize(testResultsDir.get().asFile.toPath()).toString())
    }
}
gradle -q showDirs 的输出
> gradle -q showDirs
my-reports
build/my-test-results

单击约定属性链接了解更多详细信息。

还有一个独立的 TestReport 任务类型,您可以使用它来生成自定义 HTML 测试报告。它只需要 destinationDir 的值和您想要包含在报告中的测试结果。这是一个示例,它为所有子项目的单元测试生成一个组合报告

buildSrc/src/main/kotlin/myproject.java-conventions.gradle.kts
plugins {
    id("java")
}

// Disable the test report for the individual test task
tasks.named<Test>("test") {
    reports.html.required = false
}

// Share the test report data to be aggregated for the whole project
configurations.create("binaryTestResultsElements") {
    isCanBeResolved = false
    isCanBeConsumed = true
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION))
        attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named("test-report-data"))
    }
    outgoing.artifact(tasks.test.map { task -> task.getBinaryResultsDirectory().get() })
}
build.gradle.kts
val testReportData by configurations.creating {
    isCanBeConsumed = false
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION))
        attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named("test-report-data"))
    }
}

dependencies {
    testReportData(project(":core"))
    testReportData(project(":util"))
}

tasks.register<TestReport>("testReport") {
    destinationDirectory = reporting.baseDirectory.dir("allTests")
    // Use test results from testReportData configuration
    testResults.from(testReportData)
}
buildSrc/src/main/groovy/myproject.java-conventions.gradle
plugins {
    id 'java'
}

// Disable the test report for the individual test task
test {
    reports.html.required = false
}

// Share the test report data to be aggregated for the whole project
configurations {
    binaryTestResultsElements {
        canBeResolved = false
        canBeConsumed = true
        attributes {
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.DOCUMENTATION))
            attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named(DocsType, 'test-report-data'))
        }
        outgoing.artifact(test.binaryResultsDirectory)
    }
}
build.gradle
// A resolvable configuration to collect test reports data
configurations {
    testReportData {
        canBeConsumed = false
        attributes {
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.DOCUMENTATION))
            attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named(DocsType, 'test-report-data'))
        }
    }
}

dependencies {
    testReportData project(':core')
    testReportData project(':util')
}

tasks.register('testReport', TestReport) {
    destinationDirectory = reporting.baseDirectory.dir('allTests')
    // Use test results from testReportData configuration
    testResults.from(configurations.testReportData)
}

在此示例中,我们使用约定插件 myproject.java-conventions 将项目的测试结果暴露给 Gradle 的 变体感知依赖管理引擎

该插件声明了一个可消费的 binaryTestResultsElements 配置,它表示 test 任务的二进制测试结果。在聚合项目的构建文件中,我们声明 testReportData 配置并依赖于所有我们想要聚合结果的项目。Gradle 将自动从每个子项目中选择二进制测试结果变体,而不是项目的 jar 文件。最后,我们添加一个 testReport 任务,该任务聚合 testResultsDirs 属性中的测试结果,该属性包含从 testReportData 配置解析的所有二进制测试结果。

您应该注意,TestReport 类型结合了多个测试任务的结果,并且需要聚合单个测试类的结果。这意味着如果一个给定的测试类被多个测试任务执行,那么测试报告将包括该类的执行,但很难区分该类的各个执行及其输出。

通过 XML 文件将测试结果传达给 CI 服务器和其他工具

Test 任务创建描述测试结果的 XML 文件,采用“JUnit XML”伪标准格式。此标准由 JUnit 4、JUnit Jupiter 和 TestNG 测试框架使用,并为每个框架使用相同的 DSL 块进行配置。CI 服务器和其他工具通常通过这些 XML 文件观察测试结果。

默认情况下,文件写入 layout.buildDirectory.dir("test-results/$testTaskName"),每个测试类一个文件。所有测试任务或每个测试任务都可以单独更改位置。

build.gradle.kts
java.testResultsDir = layout.buildDirectory.dir("junit-xml")
build.gradle
java.testResultsDir = layout.buildDirectory.dir("junit-xml")

通过上述配置,XML 文件将写入 layout.buildDirectory.dir("junit-xml/$testTaskName")

build.gradle.kts
tasks.test {
    reports {
        junitXml.outputLocation = layout.buildDirectory.dir("test-junit-xml")
    }
}
build.gradle
test {
    reports {
        junitXml.outputLocation = layout.buildDirectory.dir("test-junit-xml")
    }
}

通过上述配置,test 任务的 XML 文件将写入 layout.buildDirectory.dir("test-results/test-junit-xml")。其他测试任务的 XML 文件位置将保持不变。

配置选项

XML 文件的内容也可以通过配置 JUnitXmlReport 选项来配置,以不同方式传达结果。

build.gradle.kts
tasks.test {
    reports {
        junitXml.apply {
            includeSystemOutLog = false // defaults to true
            includeSystemErrLog = false // defaults to true
            isOutputPerTestCase = true // defaults to false
            mergeReruns = true // defaults to false
        }
    }
}
build.gradle
test {
    reports {
        junitXml {
            includeSystemOutLog = false // defaults to true
            includeSystemErrLog = false // defaults to true
            outputPerTestCase = true // defaults to false
            mergeReruns = true // defaults to false
        }
    }
}
includeSystemOutLog & includeSystemErrLog

includeSystemOutLog 选项允许配置是否将写入标准输出的测试输出导出到 XML 报告文件。includeSystemErrLog 选项允许配置是否将写入标准错误的测试错误输出导出到 XML 报告文件。

这些选项影响测试套件级别输出(例如 @BeforeClass/@BeforeAll 输出)以及测试类和方法特定输出(@Before/@BeforeEach@Test)。如果任一选项被禁用,则通常包含该内容​​的元素将从 XML 报告文件中排除。

每个选项的默认值均为 true

outputPerTestCase

当启用 outputPerTestCase 选项时,测试用例执行期间生成的任何输出日志都将与结果中的该测试用例相关联。当禁用(默认)时,输出与整个测试类关联,而不是与生成日志输出的各个测试用例(例如测试方法)关联。大多数观察 JUnit XML 文件的现代工具都支持“每个测试用例输出”格式。

如果您使用 XML 文件来传达测试结果,建议启用此选项,因为它提供了更有用的报告。

mergeReruns

mergeReruns 启用时,如果一个测试失败但随后重试并成功,其失败将被记录为 <flakyFailure> 而不是 <failure>,在一个 <testcase> 中。这实际上是 Apache Maven™ 的 surefire 插件在启用重试时产生的报告。如果您的 CI 服务器理解此格式,它将指示测试不稳定。如果它不理解,它将指示测试成功,因为它将忽略 <flakyFailure> 信息。如果测试不成功(即每次重试都失败),无论您的工具是否理解此格式,它都将被指示为失败。

mergeReruns 禁用(默认)时,测试的每次执行都将列为一个单独的测试用例。

如果您使用 Build ScanDevelocity,无论此设置如何,都会检测到不稳定测试。

启用此选项特别有用,当您使用 CI 工具时,该工具使用 XML 测试结果来确定构建失败,而不是依赖 Gradle 对构建是否失败的判断,并且您希望在所有失败的测试在重试时通过时,不将构建视为失败。Jenkins CI 服务器及其 JUnit 插件就是这种情况。启用 mergeReruns 后,重试通过的测试将不再导致此 Jenkins 插件将构建视为失败。但是,失败的测试执行将从 Jenkins 测试结果可视化中省略,因为它不考虑 <flakyFailure> 信息。除了 JUnit Jenkins 插件之外,还可以使用单独的 Flaky Test Handler Jenkins 插件来可视化此类“不稳定失败”。

测试根据其报告的名称进行分组和合并。当使用任何影响报告测试名称的测试参数化,或任何其他产生潜在动态测试名称的机制时,应注意确保测试名称稳定且不会不必要地更改。

启用 mergeReruns 选项不会向测试执行添加任何重试/重新运行功能。重新运行可以通过测试执行框架(例如 JUnit 的 @RepeatedTest)或通过单独的 Test Retry Gradle 插件启用。

测试检测

默认情况下,Gradle 将运行它检测到的所有测试,它通过检查编译后的测试类来实现。此检测根据所使用的测试框架使用不同的标准。

对于 JUnit,Gradle 扫描 JUnit 3 和 JUnit 4 测试类。如果一个类满足以下条件,则被认为是 JUnit 测试

  • 最终继承自 TestCaseGroovyTestCase

  • 带有 @RunWith 注解

  • 包含带有 @Test 注解的方法,或者其超类包含带有 @Test 注解的方法

对于 TestNG,Gradle 扫描带有 @Test 注解的方法。

请注意,抽象类不会被执行。此外,请注意 Gradle 会扫描继承树向上进入测试类路径上的 jar 文件。因此,如果这些 JAR 包含测试类,它们也将被运行。

如果您不想使用测试类检测,可以通过将 Test 上的 scanForTestClasses 属性设置为 false 来禁用它。当您这样做时,测试任务仅使用 includesexcludes 属性来查找测试类。

如果 scanForTestClasses 为 false 且未指定 include 或 exclude 模式,Gradle 默认为运行与模式 **/*Tests.class**/*Test.class 匹配的任何类,但排除与 **/Abstract*.class 匹配的类。

使用 JUnit Platform,仅使用 includesexcludes 过滤测试类 — scanForTestClasses 无效。

测试日志

Gradle 允许对记录到控制台的事件进行精细控制。日志记录可以按每个日志级别进行配置,默认情况下,以下事件会被记录

当日志级别为

记录的事件

附加配置

ERRORQUIETWARNING

LIFECYCLE

测试失败

异常格式为 SHORT

INFO

测试失败跳过的测试测试标准输出测试标准错误

堆栈跟踪被截断。

DEBUG

所有事件

记录完整的堆栈跟踪。

通过调整测试任务的 testLogging 属性中相应的 TestLogging 实例,可以根据每个日志级别修改测试日志。例如,要调整 INFO 级别测试日志配置,请修改 TestLoggingContainer.getInfo() 属性。

测试分组

JUnit、JUnit Platform 和 TestNG 允许对测试方法进行复杂分组。

本节适用于将单个测试类或方法分组到服务于相同测试目的(单元测试、集成测试、验收测试等)的测试集合中。有关根据其目的划分测试类的信息,请参阅孵化中的 JVM Test Suite 插件。

JUnit 4.8 引入了类别概念,用于对 JUnit 4 测试类和方法进行分组。[1] Test.useJUnit(org.gradle.api.Action) 允许您指定要包含和排除的 JUnit 类别。例如,以下配置在 test 任务中包含 CategoryA 中的测试并排除 CategoryB 中的测试

示例 8. JUnit 类别
build.gradle.kts
tasks.test {
    useJUnit {
        includeCategories("org.gradle.junit.CategoryA")
        excludeCategories("org.gradle.junit.CategoryB")
    }
}
build.gradle
test {
    useJUnit {
        includeCategories 'org.gradle.junit.CategoryA'
        excludeCategories 'org.gradle.junit.CategoryB'
    }
}

JUnit Platform 引入了标记来取代类别。您可以通过 Test.useJUnitPlatform(org.gradle.api.Action) 指定包含/排除的标记,如下所示

build.gradle.kts
tasks.withType<Test>().configureEach {
    useJUnitPlatform {
        includeTags("fast")
        excludeTags("slow")
    }
}
build.gradle
tasks.withType(Test).configureEach {
    useJUnitPlatform {
        includeTags 'fast'
        excludeTags 'slow'
    }
}

TestNG 框架使用测试组的概念来实现类似的效果。[2] 您可以通过 Test.useTestNG(org.gradle.api.Action) 设置来配置在测试执行期间要包含或排除哪些测试组,如下所示

build.gradle.kts
tasks.named<Test>("test") {
    useTestNG {
        val options = this as TestNGOptions
        options.excludeGroups("integrationTests")
        options.includeGroups("unitTests")
    }
}
build.gradle
test {
    useTestNG {
        excludeGroups 'integrationTests'
        includeGroups 'unitTests'
    }
}

使用 JUnit 5

JUnit 5 是知名 JUnit 测试框架的最新版本。与其前身不同,JUnit 5 是模块化的,由多个模块组成

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

JUnit Platform 作为在 JVM 上启动测试框架的基础。JUnit Jupiter 是新的编程模型扩展模型的组合,用于在 JUnit 5 中编写测试和扩展。JUnit Vintage 提供了一个 TestEngine,用于在平台上运行基于 JUnit 3 和 JUnit 4 的测试。

以下代码在 build.gradle 中启用 JUnit Platform 支持

build.gradle.kts
tasks.named<Test>("test") {
    useJUnitPlatform()
}
build.gradle
tasks.named('test', Test) {
    useJUnitPlatform()
}

有关更多详细信息,请参阅 Test.useJUnitPlatform()

编译和执行 JUnit Jupiter 测试

要在 Gradle 中启用 JUnit Jupiter 支持,您只需添加以下依赖项

build.gradle.kts
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.7.1")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
build.gradle
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

然后,您可以将测试用例正常放入 src/test/java 中,并使用 gradle test 执行它们。

使用 JUnit Vintage 执行旧版测试

如果您想在 JUnit Platform 上运行 JUnit 3/4 测试,甚至将它们与 Jupiter 测试混合,您应该添加额外的 JUnit Vintage Engine 依赖项

build.gradle.kts
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.7.1")
    testCompileOnly("junit:junit:4.13")
    testRuntimeOnly("org.junit.vintage:junit-vintage-engine")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
build.gradle
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
    testCompileOnly 'junit:junit:4.13'
    testRuntimeOnly 'org.junit.vintage:junit-vintage-engine'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

这样,您就可以使用 gradle test 在 JUnit Platform 上测试 JUnit 3/4 测试,而无需重写它们。

过滤测试引擎

JUnit Platform 允许您使用不同的测试引擎。JUnit 目前提供了两种开箱即用的 TestEngine 实现:junit-jupiter-enginejunit-vintage-engine。您还可以编写并插入自己的 TestEngine 实现,如此处所述。

默认情况下,测试运行时类路径上的所有测试引擎都将被使用。要明确控制特定的测试引擎实现,您可以将以下设置添加到您的构建脚本中

build.gradle.kts
tasks.withType<Test>().configureEach {
    useJUnitPlatform {
        includeEngines("junit-vintage")
        // excludeEngines("junit-jupiter")
    }
}
build.gradle
tasks.withType(Test).configureEach {
    useJUnitPlatform {
        includeEngines 'junit-vintage'
        // excludeEngines 'junit-jupiter'
    }
}

TestNG 中的测试执行顺序

当您使用 testng.xml 文件时,TestNG 允许显式控制测试的执行顺序。如果没有这样的文件——或通过 TestNGOptions.getSuiteXmlBuilder() 配置的等效文件——您无法指定测试执行顺序。但是,您可以控制测试的所有方面(包括其相关的 @BeforeXXX@AfterXXX 方法,例如带有 @Before/AfterClass@Before/AfterMethod 注解的方法)是否在下一个测试开始之前执行。您可以通过将 TestNGOptions.getPreserveOrder() 属性设置为 true 来实现。如果将其设置为 false,您可能会遇到执行顺序如下的场景:TestA.doBeforeClass()TestB.doBeforeClass()TestA 测试。

虽然在直接使用 testng.xml 文件时保留测试顺序是默认行为,但 Gradle 的 TestNG 集成使用的 TestNG API 默认以不可预测的顺序执行测试。[3] 保留测试执行顺序的功能是在 TestNG 5.14.5 版本中引入的。将旧版 TestNG 的 preserveOrder 属性设置为 true 将导致构建失败。

build.gradle.kts
tasks.test {
    useTestNG {
        preserveOrder = true
    }
}
build.gradle
test {
    useTestNG {
        preserveOrder = true
    }
}

groupByInstance 属性控制测试是否应按实例而非按类分组。TestNG 文档更详细地解释了其中的区别,但本质上,如果您有一个测试方法 A() 依赖于 B(),则按实例分组可确保每个 A-B 配对(例如 B(1)-A(1))在下一个配对之前执行。如果按类分组,则所有 B() 方法都会运行,然后所有 A() 方法都会运行。

请注意,如果您使用数据提供者来参数化测试,通常只有一个以上的测试实例。此外,按实例分组测试是在 TestNG 6.1 版本中引入的。将旧版 TestNG 的 groupByInstances 属性设置为 true 将导致构建失败。

build.gradle.kts
tasks.test {
    useTestNG {
        groupByInstances = true
    }
}
build.gradle
test {
    useTestNG {
        groupByInstances = true
    }
}

TestNG 参数化方法和报告

TestNG 支持参数化测试方法,允许一个特定的测试方法用不同的输入执行多次。Gradle 在其测试方法执行报告中包含参数值。

给定一个名为 aTestMethod 的参数化测试方法,它接受两个参数,它将以 aTestMethod(toStringValueOfParam1, toStringValueOfParam2) 的名称报告。这使得识别特定迭代的参数值变得容易。

配置集成测试

项目的一个常见要求是以某种形式整合集成测试。其目的是验证项目的各个部分是否正常协同工作。这通常意味着与单元测试相比,它们需要特殊的执行设置和依赖项。

向构建中添加集成测试的最简单方法是利用孵化中的 JVM Test Suite 插件。如果孵化中的解决方案不适合您,以下是您需要在构建中采取的步骤

  1. 为它们创建一个新的源集

  2. 将您需要的依赖项添加到该源集的相应配置中

  3. 配置该源集的编译和运行时类路径

  4. 创建运行集成测试的任务

您可能还需要根据集成测试的形式执行一些额外的配置。我们将在接下来的讨论中涉及这些。

让我们从一个实际的例子开始,它在一个构建脚本中实现了前三个步骤,围绕一个新的源集 intTest

build.gradle.kts
sourceSets {
    create("intTest") {
        compileClasspath += sourceSets.main.get().output
        runtimeClasspath += sourceSets.main.get().output
    }
}

val intTestImplementation by configurations.getting {
    extendsFrom(configurations.implementation.get())
}
val intTestRuntimeOnly by configurations.getting

configurations["intTestRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get())

dependencies {
    intTestImplementation("org.junit.jupiter:junit-jupiter:5.7.1")
    intTestRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
build.gradle
sourceSets {
    intTest {
        compileClasspath += sourceSets.main.output
        runtimeClasspath += sourceSets.main.output
    }
}

configurations {
    intTestImplementation.extendsFrom implementation
    intTestRuntimeOnly.extendsFrom runtimeOnly
}

dependencies {
    intTestImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
    intTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

这将设置一个新的源集 intTest,它将自动创建

  • intTestImplementationintTestCompileOnlyintTestRuntimeOnly 配置(以及其他一些不那么常用的配置)

  • 一个 compileIntTestJava 任务,它将编译 src/intTest/java 下的所有源文件

如果您正在使用 IntelliJ IDE,您可能希望将这些附加源集中的目录标记为包含测试源而不是生产源,如 Idea Plugin 文档中所述。

该示例还执行以下操作,其中并非所有操作都可能适用于您的特定集成测试

  • main 源集中的生产类添加到集成测试的编译和运行时类路径中——sourceSets.main.output 是包含已编译生产类和资源的所有目录的文件集合

  • 使 intTestImplementation 配置从 implementation 继承,这意味着生产代码的所有声明依赖项也成为集成测试的依赖项

  • intTestRuntimeOnly 配置执行相同操作

在大多数情况下,您希望集成测试能够访问被测类,这就是为什么我们在此示例中确保将这些类包含在编译和运行时类路径中。但某些类型的测试以不同的方式与生产代码交互。例如,您可能有一些测试将您的应用程序作为可执行文件运行并验证输出。在 Web 应用程序的情况下,测试可能会通过 HTTP 与您的应用程序交互。由于在这种情况下测试不需要直接访问被测类,因此您不需要将生产类添加到测试类路径中。

另一个常见的步骤是将所有单元测试依赖项也附加到集成测试中——通过 intTestImplementation.extendsFrom testImplementation——但这只有在集成测试需要所有或几乎所有与单元测试相同的依赖项时才有意义。

您还应该注意该示例的其他几个方面

  • += 允许您将路径和路径集合附加到 compileClasspathruntimeClasspath,而不是覆盖它们

  • 如果您想使用基于约定的配置(例如 intTestImplementation),您必须在新的源集之后声明依赖项

创建和配置源集会自动设置编译阶段,但与运行集成测试无关。因此,难题的最后一部分是自定义测试任务,它使用新源集的信息来配置其运行时类路径和测试类

build.gradle.kts
val integrationTest = task<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"

    testClassesDirs = sourceSets["intTest"].output.classesDirs
    classpath = sourceSets["intTest"].runtimeClasspath
    shouldRunAfter("test")

    useJUnitPlatform()

    testLogging {
        events("passed")
    }
}

tasks.check { dependsOn(integrationTest) }
build.gradle
tasks.register('integrationTest', Test) {
    description = 'Runs integration tests.'
    group = 'verification'

    testClassesDirs = sourceSets.intTest.output.classesDirs
    classpath = sourceSets.intTest.runtimeClasspath
    shouldRunAfter test

    useJUnitPlatform()

    testLogging {
        events "passed"
    }
}

check.dependsOn integrationTest

同样,我们正在访问一个源集以获取相关信息,即编译后的测试类在哪里——testClassesDirs 属性——以及运行它们时需要什么在类路径上——classpath

用户通常希望在单元测试之后运行集成测试,因为它们通常运行速度较慢,并且您希望构建在单元测试上早些失败,而不是在集成测试上晚些失败。这就是为什么上面的示例添加了一个 shouldRunAfter() 声明。这优于 mustRunAfter(),以便 Gradle 在并行执行构建时具有更大的灵活性。

有关如何确定附加源集中测试的代码覆盖率的信息,请参阅 JaCoCo 插件JaCoCo 报告聚合插件 章。

常见问题故障排除

未检测到或执行测试

如果未检测到任何测试,Gradle 将完全跳过 Test 任务。

这可能由以下几个原因造成

  • 不存在测试源。这对于空的测试套件是预期行为。

  • 测试源存在但未编译。这可能发生在

    • 源文件位于错误的目录中。

    • 源集配置不正确。

  • Test 任务上未配置测试类。默认情况下,只有内置的 Test 任务会自动配置为使用测试源集。如果您使用的是自定义 Test 任务,则必须手动配置 testClassesDirsclasspath

在 Gradle 9.0.0 之前,所有 Test 任务都隐式配置为使用 test 源集。

测试进程无法启动或保持运行

如果配置不正确,测试进程可能无法启动。例如,如果 Java 可执行文件不存在或提供了无效的 JVM 参数,测试进程将无法启动。

同样,如果测试需要对环境进行不兼容 Gradle 测试工作进程的更改,测试进程可能会启动失败。例如,依赖于修改 SecurityManager 的测试可能会干扰 Gradle 构建和测试工作进程之间的内部消息通信。

测试进程启动但测试依赖项缺失

一旦测试进程启动,如果测试运行时类路径中缺少所需的依赖项,则可能无法找到它们。

测试依赖项需要声明在适当的配置上。JVM 测试套件插件会自动配置所有必要的依赖项,但如果您手动配置 Test 任务,则需要确保所有必需的依赖项都声明在适当的配置上。

对于基于 JUnit4 的测试,您需要声明对单个 JUnit 4.x 库的依赖。对于基于 TestNG 的测试,您需要声明对单个 TestNG 库的依赖。对于基于 JUnit Jupiter 的测试,您需要声明对 JUnit Jupiter 和 JUnit 平台库的依赖。

在 Gradle 9.0.0 之前,Gradle 将内部依赖项泄露到测试运行时类路径中,这可能会掩盖缺失的依赖项。

测试进程以异常退出代码失败

测试进程可能以异常退出代码(非零)退出。

这可能是由各种问题引起的,例如

  • 测试直接调用 System.exit()

  • 运行的进程过多导致操作系统终止测试进程。

  • 测试进程内存不足。

测试 Java 模块

如果您正在开发 Java 模块,本章中描述的所有内容仍然适用,并且可以使用任何受支持的测试框架。但是,根据您是否需要模块信息可用以及是否需要在测试执行期间强制执行模块边界,需要考虑一些事项。在这种情况下,通常使用术语白盒测试(模块边界被停用或放松)和黑盒测试(模块边界到位)。白盒测试用于/需要进行单元测试,黑盒测试适用于功能或集成测试要求。

类路径上的白盒单元测试执行

为模块中的函数或类编写单元测试最简单的设置是在测试执行期间使用模块特定功能。为此,您只需像为普通库编写测试一样编写测试。如果您的测试源集 (src/test/java) 中没有 module-info.java 文件,则此源集在编译和测试运行时将被视为传统 Java 库。这意味着所有依赖项,包括带有模块信息的 Jar,都放在类路径上。优点是您的(或其他)模块的所有内部类都可以直接在测试中访问。这对于单元测试来说可能是一个完全有效的设置,因为我们不关心更大的模块结构,只关心测试单个函数。

如果您正在使用 Eclipse:默认情况下,Eclipse 也将单元测试作为模块运行,使用模块修补(请参阅下文)。在导入的 Gradle 项目中,使用 Eclipse 测试运行器测试模块可能会失败。然后,您需要手动调整测试运行配置中的类路径/模块路径,或将测试执行委托给 Gradle。

这只涉及测试执行。单元测试编译和开发在 Eclipse 中运行良好。

黑盒集成测试

对于集成测试,您可以选择将测试集本身定义为附加模块。您可以通过向相应的源集添加 module-info.java 文件(例如 integrationTests/java/module-info.java)来完成此操作,类似于将主源转换为模块的方式。

您可以在此处找到一个包含黑盒集成测试的完整示例。

在 Eclipse 中,目前不支持在一个项目中编译多个模块。因此,此处描述的集成测试(黑盒)设置仅在 Eclipse 中有效,前提是将测试移动到单独的子项目中。

使用模块修补进行白盒测试执行

白盒测试的另一种方法是留在模块世界中,通过将测试修补到被测模块中。这样,模块边界保持不变,但测试本身成为被测模块的一部分,然后可以访问模块的内部。

这与哪些用例相关以及如何最好地完成此操作是一个讨论主题。目前没有普遍的最佳方法。因此,Gradle 目前对此没有特殊支持。

但是,您可以像这样为测试设置模块修补

  • 在您的测试源集中添加一个 module-info.java,它是主 module-info.java 的副本,其中包含测试所需的附加依赖项(例如 requires org.junit.jupiter.api)。

  • 使用参数配置 testCompileJavatest 任务,以修补主类和测试类,如下所示。

build.gradle.kts
val moduleName = "org.gradle.sample"
val patchArgs = listOf("--patch-module", "$moduleName=${tasks.compileJava.get().destinationDirectory.asFile.get().path}")
tasks.compileTestJava {
    options.compilerArgs.addAll(patchArgs)
}
tasks.test {
    jvmArgs(patchArgs)
}
build.gradle
def moduleName = "org.gradle.sample"
def patchArgs = ["--patch-module", "$moduleName=${tasks.compileJava.destinationDirectory.asFile.get().path}"]
tasks.named('compileTestJava') {
    options.compilerArgs += patchArgs
}
tasks.named('test') {
    jvmArgs += patchArgs
}
如果使用自定义参数进行修补,Eclipse 和 IDEA 将不会识别这些参数。您很可能会在 IDE 中看到无效的编译错误。

跳过测试

如果您想在运行构建时跳过测试,有几种选择。您可以通过命令行参数在构建脚本中完成。要在命令行中执行此操作,您可以使用 -x--exclude-task 选项,如下所示

gradle build -x test

这会排除 test 任务以及它依赖的任何其他任务,即没有其他任务依赖于同一个任务。这些任务不会被 Gradle 标记为“SKIPPED”,而只是不会出现在执行任务列表中。

通过构建脚本跳过测试有几种方法。一种常见的方法是通过 Task.onlyIf(String, org.gradle.api.specs.Spec) 方法使测试执行具有条件。以下示例在项目具有名为 mySkipTests 的属性时跳过 test 任务

build.gradle.kts
tasks.test {
    val skipTestsProvider = providers.gradleProperty("mySkipTests")
    onlyIf("mySkipTests property is not set") {
        !skipTestsProvider.isPresent()
    }
}
build.gradle
def skipTestsProvider = providers.gradleProperty('mySkipTests')
test.onlyIf("mySkipTests property is not set") {
    !skipTestsProvider.present
}

在这种情况下,Gradle 会将跳过的测试标记为“SKIPPED”,而不是将它们从构建中排除。

强制运行测试

在定义良好的构建中,您可以依赖 Gradle 仅在测试本身或生产代码发生更改时才运行测试。但是,您可能会遇到测试依赖于第三方服务或其他可能更改但在构建中无法建模的事物的情况。

您始终可以使用 --rerun 内置任务选项来强制任务重新运行。

gradle test --rerun

或者,如果构建缓存未启用,您还可以通过清除相关 Test 任务(例如 test)的输出并再次运行测试来强制运行测试,如下所示

gradle cleanTest test

cleanTest 基于 Base Plugin 提供的任务规则。您可以将其用于任何任务。

运行测试时进行调试

在少数情况下,您希望在测试运行时调试代码,此时能够附加调试器会很有帮助。您可以将 Test.getDebug() 属性设置为 true,或使用 --debug-jvm 命令行选项,或使用 --no-debug-jvm 将其设置为 false。

启用测试调试后,Gradle 将挂起测试进程并侦听端口 5005。

您还可以在 DSL 中启用调试,在那里您还可以配置其他属性

test {
    debugOptions {
        enabled = true
        host = 'localhost'
        port = 4455
        server = true
        suspend = true
    }
}

通过此配置,测试 JVM 的行为将与传递 --debug-jvm 参数时相同,但它将侦听端口 4455。

要通过网络远程调试测试进程,host 需要设置为机器的 IP 地址或 "*"(侦听所有接口)。

使用测试夹具

在单个项目中生成和使用测试夹具

测试夹具通常用于设置被测代码,或提供旨在促进组件测试的实用程序。Java 项目可以通过应用 java-test-fixtures 插件以及 javajava-library 插件来启用测试夹具支持

lib/build.gradle.kts
plugins {
    // A Java Library
    `java-library`
    // which produces test fixtures
    `java-test-fixtures`
    // and is published
    `maven-publish`
}
lib/build.gradle
plugins {
    // A Java Library
    id 'java-library'
    // which produces test fixtures
    id 'java-test-fixtures'
    // and is published
    id 'maven-publish'
}

这将自动创建一个 testFixtures 源集,您可以在其中编写测试夹具。测试夹具的配置方式如下

  • 它们可以查看main源集类

  • test源可以查看test fixtures

例如,对于这个主类

src/main/java/com/acme/Person.java
public class Person {
    private final String firstName;
    private final String lastName;

    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    // ...

测试夹具可以写入 src/testFixtures/java

src/testFixtures/java/com/acme/Simpsons.java
public class Simpsons {
    private static final Person HOMER = new Person("Homer", "Simpson");
    private static final Person MARGE = new Person("Marjorie", "Simpson");
    private static final Person BART = new Person("Bartholomew", "Simpson");
    private static final Person LISA = new Person("Elisabeth Marie", "Simpson");
    private static final Person MAGGIE = new Person("Margaret Eve", "Simpson");
    private static final List<Person> FAMILY = new ArrayList<Person>() {{
        add(HOMER);
        add(MARGE);
        add(BART);
        add(LISA);
        add(MAGGIE);
    }};

    public static Person homer() { return HOMER; }

    public static Person marge() { return MARGE; }

    public static Person bart() { return BART; }

    public static Person lisa() { return LISA; }

    public static Person maggie() { return MAGGIE; }

    // ...

声明测试夹具的依赖项

Java 库插件类似,测试夹具暴露了 API 和实现配置

lib/build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13")

    // API dependencies are visible to consumers when building
    testFixturesApi("org.apache.commons:commons-lang3:3.9")

    // Implementation dependencies are not leaked to consumers when building
    testFixturesImplementation("org.apache.commons:commons-text:1.6")
}
lib/build.gradle
dependencies {
    testImplementation 'junit:junit:4.13'

    // API dependencies are visible to consumers when building
    testFixturesApi 'org.apache.commons:commons-lang3:3.9'

    // Implementation dependencies are not leaked to consumers when building
    testFixturesImplementation 'org.apache.commons:commons-text:1.6'
}

值得注意的是,如果依赖项是测试夹具的实现依赖项,那么在编译依赖于这些测试夹具的测试时,实现依赖项将不会泄漏到编译类路径中。这会改善关注点分离并更好地避免编译。

消费另一个项目的测试夹具

测试夹具不限于单个项目。通常情况下,依赖项目测试也需要依赖项的测试夹具。这可以使用 testFixtures 关键字轻松实现

build.gradle.kts
dependencies {
    implementation(project(":lib"))

    testImplementation("junit:junit:4.13")
    testImplementation(testFixtures(project(":lib")))
}
build.gradle
dependencies {
    implementation(project(":lib"))

    testImplementation 'junit:junit:4.13'
    testImplementation(testFixtures(project(":lib")))
}

发布测试夹具

使用 java-test-fixtures 插件的优点之一是测试夹具会被发布。根据约定,测试夹具将以 test-fixtures 分类器发布。对于 Maven 和 Ivy,具有该分类器的工件会与常规工件一起发布。但是,如果您使用 maven-publishivy-publish 插件,测试夹具将作为附加变体发布在 Gradle 模块元数据中,您可以直接在另一个 Gradle 项目中依赖外部库的测试夹具

build.gradle.kts
dependencies {
    // Adds a dependency on the test fixtures of Gson, however this
    // project doesn't publish such a thing
    functionalTest(testFixtures("com.google.code.gson:gson:2.13.1"))
}
build.gradle
dependencies {
    // Adds a dependency on the test fixtures of Gson, however this
    // project doesn't publish such a thing
    functionalTest testFixtures("com.google.code.gson:gson:2.13.1")
}

值得注意的是,如果外部项目发布 Gradle 模块元数据,则解析将失败并显示错误,指示无法找到此类变体

gradle dependencyInsight --configuration functionalTestClasspath --dependency gson 的输出
> gradle dependencyInsight --configuration functionalTestClasspath --dependency gson

> Task :dependencyInsight
com.google.code.gson:gson:2.13.1 FAILED
   Failures:
      - Could not resolve com.google.code.gson:gson:2.13.1.
          - Unable to find a variant with the requested capability: feature 'test-fixtures':
               - Variant 'compile' provides 'com.google.code.gson:gson:2.13.1'
               - Variant 'enforced-platform-compile' provides 'com.google.code.gson:gson-derived-enforced-platform:2.13.1'
               - Variant 'enforced-platform-runtime' provides 'com.google.code.gson:gson-derived-enforced-platform:2.13.1'
               - Variant 'javadoc' provides 'com.google.code.gson:gson:2.13.1'
               - Variant 'platform-compile' provides 'com.google.code.gson:gson-derived-platform:2.13.1'
               - Variant 'platform-runtime' provides 'com.google.code.gson:gson-derived-platform:2.13.1'
               - Variant 'runtime' provides 'com.google.code.gson:gson:2.13.1'
               - Variant 'sources' provides 'com.google.code.gson:gson:2.13.1'

com.google.code.gson:gson:2.13.1 FAILED
\--- functionalTestClasspath

A web-based, searchable dependency report is available by adding the --scan option.

BUILD SUCCESSFUL in 0s
1 actionable task: 1 executed

错误消息提及缺失的 com.google.code.gson:gson-test-fixtures 功能,该功能在此库中确实未定义。这是因为根据约定,对于使用 java-test-fixtures 插件的项目,Gradle 会自动创建测试夹具变体,其功能名称是主组件的名称,并带有后缀 -test-fixtures

如果您发布库并使用测试夹具,但不想发布夹具,可以按如下所示禁用测试夹具变体的发布。
build.gradle.kts
val javaComponent = components["java"] as AdhocComponentWithVariants
javaComponent.withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() }
javaComponent.withVariantsFromConfiguration(configurations["testFixturesRuntimeElements"]) { skip() }
build.gradle
components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() }
components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() }

1. JUnit wiki 包含有关如何使用 JUnit 类别的详细描述:https://github.com/junit-team/junit/wiki/Categories
2. TestNG 文档包含有关测试组的更多详细信息:https://testng.org/#_test_groups
3. TestNG 文档包含有关使用 testng.xml 文件时测试顺序的更多详细信息:http://testng.org/doc/documentation-main.html#testng-xml