测试工具
如果插件依赖于 Android Gradle Plugin (AGP)(或任何第三方生态系统插件),应该强烈考虑将其声明为 compileOnly。
你不知道你的用户会做什么,所以你应该假设他们会做任何事情。想象一下,如果一个插件可以默默地改变你的构建所针对的 Gradle 版本。它(几乎)那么糟糕!
虽然我认为上述解释足以证明 compileOnly 建议的合理性,但我也可以用实际问题证明它的合理性。
这是探索我们的问题空间的规范的第一次迭代:
class AndroidSpec extends Specification {
@AutoCleanup
AbstractProject project
def "gets the expected version of AGP on the classpath (#gradleVersion AGP #agpVersion)"() {
given: 'An Android Gradle project'
project = new AndroidProject(agpVersion)
when: 'We check the version of AGP on the classpath'
def result = Builder.build(
gradleVersion,
project,
'lib:which', '-e', 'android'
)
// The output will contain a line like this:
// jar for 'android': file:/path/to/gradle-all-the-way-down/plugin/build/tmp/functionalTest/work/.gradle-test-kit/caches/jars-9/f19c6db5e8f27caa4113e88608762369/gradle-4.2.2.jar
then: 'It matches what the project provides, not what the plugin compiles against'
def androidJar = result.output.split('\n').find {
it.startsWith("jar for 'android'")
}
assertThat(androidJar).endsWith("gradle-${agpVersion}.jar")
where:
[gradleVersion, agpVersion] << gradleAgpCombinations()
}
}
让我们先从高层次理解流程:
我们创建了一个用于测试的 Android 项目。
我们使用选项 -e android 运行一个名为 which 的任务。
我们断言我们通过步骤 2 找到的 jar 具有正确的版本信息。
我们针对 Gradle 和 AGP 版本的矩阵运行整个事情,因为我们是彻底的。
这是从 IDE 运行时该规范的样子:
$ ./gradlew plugin:functionalTest --tests AndroidSpec
> Task :plugin:functionalTest
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.3.3 AGP 4.2.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.4.1 AGP 4.2.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.3.3 AGP 7.1.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.4.1 AGP 7.1.2)(mutual.aid.AndroidSpec)
这就是Spock的力量。为参数化测试生成数据管道真的很容易。
数据驱动测试
由于数据驱动方面对于理解我们正在探索的概念非常重要,我想展示它的实现:
inal class Combinations {
static List<List> gradleAgpCombinations(
List<Object>... others = []
) {
return [
gradleVersions(), agpVersions(), *others
].combinations()
}
static List<GradleVersion> gradleVersions() {
return [
GradleVersion.version('7.3.3'),
GradleVersion.version('7.4.1')
]
}
static List<String> agpVersions() {
return ['4.2.2', '7.1.2']
}
}
哪个 AGP?
为了以编程方式检查我们构建的运行时类路径中的 AGP 版本,我编写了一个小帮助脚本来注册我们的规范调用的任务。这是定义的方式:
// which.gradle
tasks.register('which', WhichTask)
@UntrackedTask(because = 'Not worth tracking')
abstract class WhichTask extends DefaultTask {
WhichTask() {
group = 'Help'
description = 'Print path to jar providing extension, or list of all available extensions and their types'
}
@Optional
@Option(option = 'e', description = 'Which extension?')
@Input
abstract String ext
@TaskAction def action() {
if (ext) printLocation()
else printExtensions()
}
private void printLocation() {
def jar = project.extensions.findByName(ext)
?.class
?.protectionDomain
?.codeSource
?.location
if (jar) {
logger.quiet("jar for '$ext': $jar")
} else {
logger.quiet("No extension named '$ext' registered on project.")
}
}
private void printExtensions() {
logger.quiet('Available extensions:')
project.extensions.extensionsSchema.elements.sort { it.name }.each {
// fullyQualifiedName since Gradle 7.4
logger.quiet("* ${it.name}, ${it.publicType.fullyQualifiedName}")
}
}
}
此任务可以以两种模式运行:
./gradlew which -e <some-extension>
./gradlew which
第一个将打印提供扩展名的 jar 的路径(例如“android”),而第二个将打印给定模块可用的所有扩展名,以及它们的完全限定类型.
测试场景
首先,我在插件的构建脚本中添加了一个标志,让我可以更改它的构建方式,以便我可以通过自动化测试来探索这种行为。这不是我在一般情况下会推荐的东西——它只是用于驱动以下探索性场景的断言。
// plugin/build.gradle
// -Dimpl (for 'implementation')
boolean impl = providers.systemProperty('impl').orNull != null
dependencies {
if (impl) {
implementation 'com.android.tools.build:gradle:7.2.0-beta04'
} else {
compileOnly 'com.android.tools.build:gradle:7.2.0-beta04'
}
}
默认情况下,我们使用 compileOnly,但如果您在构建期间传递 -Dimpl,我们将使用 implementation。我们还必须更新我们的测试配置,因为我们需要测试 JVM 中可用的标志(它是从主 JVM 派生的,默认情况下不会获取其所有系统属性)。
// plugin/build.gradle
testTask.configure {
...
systemProperty('impl', impl)
...
}
迭代 1:用户是否在根 buildscript 块中声明 AGP 是否重要?
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScript=#useBuildScript)"() {
given: 'An Android Gradle project'
project = new AndroidProject(agpVersion,useBuildScript)
when: 'We check the version of AGP on the classpath'
def result = Builder.build(
gradleVersion,
project,
'lib:which', '-e', 'android'
)
then: 'Result depends'
def androidJar = result.output.split('\n').find {
it.startsWith("jar for 'android'")
}
def expected = useBuildScript
? "gradle-${agpVersion}.jar"
: 'gradle-7.2.0-beta04.jar'
assertThat(androidJar).endsWith(expected)
where: '2^3=8 combinations'
[gradleVersion, agpVersion, useBuildScript] << gradleAgpCombinations([true, false])
}
实际上,这些场景映射到以下两个 Gradle 构建脚本:
// settings.gradle -- for BOTH versions of the build script
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
plugins {
// Centralized version declarations. These do not directly
// impact the classpath. Rather, this simply lets you have
// a single place to declare all plugin versions.
id 'com.android.library' version '7.1.2'
}
}
// build.gradle 1
// useBuildScript = false
plugins {
id 'com.android.library' apply false
}
// build.gradle 2
// useBuildScript = true
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:$agpVersion"
}
}
从我们的规范通过的事实来看,我们可以自信地回答标题中的问题:是的,结果取决于用户是否在根项目的 buildscript 块中声明了 AGP。对于不熟悉 Android 的用户,请注意这是自古以来的标准做法,并且仅随着最新模板开始更改。
当然,这已经是有问题的了:我们已经创建了一个插件已经成功地将我们的构建升级到 AGP 的 beta 版本的场景。
迭代 2:如果我们关闭一些 TestKit 魔法会发生什么?
TestKit 中有一点魔力,可以将您的插件置于构建类路径中,这样您就不必这样做了。它对于简单的场景非常有用,但我发现它缺乏工业规模的用例。要测试这些场景,首先我们必须更新我们的构建脚本:
// plugin/build.gradle
plugins {
...
id 'maven-publish'
}
group = 'mutual.aid'
version = '1.0'
// Some specs rely on the plugin as an external artifact
// This task is added to the build by the maven-publish plugin
def publishToMavenLocal = tasks.named('publishToMavenLocal')
testTask.configure {
...
dependsOn(publishToMavenLocal)
...
}
现在,每当我们运行 functionalTest 任务时,它都会首先将我们的插件发布到本地 maven (~/.m2/repositories)。
现在对 Builder 进行更改,让我们改变这种行为:
private fun runner(
gradleVersion: GradleVersion,
projectDir: Path,
withPluginClasspath: Boolean,
vararg args: String
): GradleRunner = GradleRunner.create().apply {
...
if (withPluginClasspath) {
withPluginClasspath()
}
...
}
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScript=#useBuildScript useMavenLocal=#useMavenLocal)"() {
given: 'An Android Gradle project'
project = new AndroidProject(
agpVersion,
useBuildScript,
useMavenLocal
)
when: 'We check the version of AGP on the classpath'
def result = Builder.build(
gradleVersion,
project,
// !useMavenLocal => withPluginClasspath
!useMavenLocal,
'lib:which', '-e', 'android'
)
then: 'Result depends'
def androidJar = result.output.split('\n').find {
it.startsWith("jar for 'android'")
}
def expected
if (useBuildScript || useMavenLocal) {
expected = "gradle-${agpVersion}.jar"
} else {
expected = 'gradle-7.2.0-beta04.jar'
}
// Our assertion is growing more complicated
assertThat(androidJar).endsWith(expected)
where: '2^4=16 combinations'
[gradleVersion, agpVersion, useBuildScript, useMavenLocal] << gradleAgpCombinations(
// useBuildScript
[true, false],
// useMavenLocal
[true, false],
)
}
实际上,这些场景映射到以下四个 Gradle 构建脚本:
// settings.gradle now varies
pluginManagement {
repositories {
if (useMavenLocal) mavenLocal()
gradlePluginPortal()
google()
mavenCentral()
}
}
// build.gradle 1
// useBuildScript = true
// useMavenLocal = false
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:$agpVersion"
}
}
// build.gradle 2
// useBuildScript = true
// useMavenLocal = true
buildscript {
repositories {
mavenLocal()
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:$agpVersion"
}
}
// build.gradle 3
// useBuildScript = false
// useMavenLocal = true
plugins {
id 'com.android.library' apply false
}
// build.gradle 4 (identical to 3, but recall settings.gradle
// varies)
// useBuildScript = false
// useMavenLocal = false
plugins {
id 'com.android.library' apply false
}
由于我们的规范已经通过,我们知道是的,TestKit 类路径魔法会影响我们构建的结果。由于 TestKit 在实际构建中没有发挥作用,因此我更喜欢不使用 withPluginClasspath() 方法,而是始终依赖于将我的插件发布到本地 maven,因为它更接近于真实构建。
迭代 3:我们是否为被测插件使用 buildscript 是否重要?
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScriptForAgp=#useBuildScriptForAgp useBuildScriptForPlugin=#useBuildScriptForPlugin useMavenLocal=#useMavenLocal)"() {
given: 'An Android Gradle project'
project = new AndroidProject(
agpVersion,
useBuildScriptForAgp,
useBuildScriptForPlugin,
useMavenLocal
)
when: 'We check the version of AGP on the classpath'
def result = Builder.build(
gradleVersion,
project,
!useMavenLocal,
'lib:which', '-e', 'android'
)
then: 'Result depends'
def androidJar = result.output.split('\n').find {
it.startsWith("jar for 'android'")
}
def expected
if (useBuildScriptForAgp && useBuildScriptForPlugin && useMavenLocal) {
// our 'implementation' dependency has greater priority
expected = 'gradle-7.2.0-beta04.jar'
} else if (useBuildScriptForAgp || useMavenLocal) {
// the project's requirements have greater priority
expected = "gradle-${agpVersion}.jar"
} else {
// our 'implementation' dependency has greater priority
expected = 'gradle-7.2.0-beta04.jar'
}
// Note that the assertion is... complicated.
assertThat(androidJar).endsWith(expected)
where: 'There is a truly atrocious combinatorial explosion'
[gradleVersion, agpVersion, useBuildScriptForAgp, useBuildScriptForPlugin, useMavenLocal] <<
gradleAgpCombinations(
// useBuildScriptForAgp
[true, false],
// useBuildScriptForPlugin
[true, false],
// useMavenLocal
[true, false],
)
}
这一次,我不会分享所有的个体变化,而是将 if/else 逻辑留在原处,以便您可以发挥您的想象力。请注意,以下合并了一些伪代码以提高可读性。
// settings.gradle
pluginManagement {
repositories {
if (useMavenLocal) mavenLocal()
gradlePluginPortal()
google()
mavenCentral()
}
plugins {
id 'com.android.library' version '7.1.2'
id 'mutual.aid.meaning-of-life' version '1.0'
}
}
// build.gradle
if (useBuildScriptForAgp) {
buildscript {
repositories {
if (useMavenLocal) mavenLocal()
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.2'
if (useBuildScriptForPlugin) classpath
'mutual.aid.meaning-of-life:mutual.aid.meaning-of-life.gradle.plugin:1.0'
}
}
} else {
plugins {
id 'com.android.library' apply false
}
}
现在你将在实际构建中最终得到的 AGP 版本现在很难预测,除非你是专家并且了解类加载器关系是如何工作的,以及这些关系如何相互作用(或不交互)与依赖解析引擎。
迭代 4:我们可以(而且必须)做得更好!或者,零假设。
乍一看,它可能看起来很复杂,但请注意断言总是相同的。也就是说,无论我们传递什么样的标志组合(即,无论我们的用户可能决定做什么),我们总是以用户指定的 AGP 版本结束。这就是 compileOnly 的强大之处。
@IgnoreIf({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for compileOnly (#gradleVersion AGP #agpVersion useBuildScriptForAgp=#useBuildScriptForAgp useBuildScriptForPlugin=#useBuildScriptForPlugin useMavenLocal=#useMavenLocal)"() {
given: 'An Android Gradle project'
project = new AndroidProject(
agpVersion,
useBuildScriptForAgp,
useBuildScriptForPlugin,
useMavenLocal
)
when: 'We check the version of AGP on the classpath'
def result = Builder.build(gradleVersion, project, 'lib:which', '-e', 'android')
then: 'It matches what the project provides, not the plugin, always'
def androidJar = result.output.split('\n').find {
it.startsWith("jar for 'android'")
}
// Note that the assertion is always the same
assertThat(androidJar).endsWith("gradle-${agpVersion}.jar")
where:
[gradleVersion, agpVersion, useBuildScriptForAgp, useBuildScriptForPlugin, useMavenLocal] <<
gradleAgpCombinations(
// useBuildScriptForAgp
[true, false],
// useBuildScriptForPlugin
[true, false],
// useMavenLocal
[true, false],
)
}
除非调试类加载器和依赖解析交互是你的日常工作,否则我强烈建议你保持简单,走老路,并使用 compileOnly。
即使你做了正确的事情并使用 compileOnly,你依赖的一些插件可能仍然使用实现并将 AGP 带入你的运行时类路径,无论你是否想要它.在将我的大型构建迁移到约定插件的过程的早期,我遇到了这个问题,并发现自己处于两个 AGP 版本最终在运行时可用的场景中! 😱 为了防止将来发生这种情况,我将以下任务添加到我所有的约定插件项目中,并在 CI 中运行它。
下面是任务实现:
@UntrackedTask(because = "Not worth tracking")
abstract class NoAgpAtRuntime : DefaultTask() {
@get:Internal
lateinit var artifacts: ArtifactCollection
@PathSensitive(PathSensitivity.RELATIVE)
@InputFiles
fun getResolvedArtifactResult(): FileCollection {
return artifacts.artifactFiles
}
@TaskAction fun action() {
val android = artifacts.artifacts
.map { it.id.componentIdentifier.displayName }
.filter { it.startsWith("com.android.tools") }
.toSortedSet()
if (android.isNotEmpty()) {
val msg = buildString {
appendLine("AGP must not be on the runtime classpath. The following AGP libs were discovered:")
android.forEach { a ->
appendLine("- $a")
}
appendLine(
"The most likely culprit is `implementation 'com.android.tools.build:gradle'` in your dependencies block"
)
}
throw GradleException(msg)
}
}
}
这是它的注册方式:
// register the task
def runtimeClasspath = project.configurations.findByName('runtimeClasspath')
if (runtimeClasspath) {
def noAgpAtRuntime = tasks.register('noAgpAtRuntime', NoAgpAtRuntime) {
artifacts = runtimeClasspath.incoming.artifacts
}
tasks.named('check').configure {
dependsOn noAgpAtRuntime
}
}
当检查失败时,解决方案是找到正在使用 AGP 实现的第三方插件,并将其声明为 compileOnly。