Skip to content

Commit 1fbb57d

Browse files
kirich1409claude
andcommitted
Add E2E integration test for Gradle plugin with Android project
Add FeaturedPluginIntegrationTest that uses Gradle TestKit with a minimal Android application fixture to verify: - generateProguardRules produces correct -assumevalues rules - assembleRelease auto-wires the generated proguard file via Variant API Tests are skipped when ANDROID_HOME is not set. The testPluginClasspath configuration injects AGP into the TestKit subprocess classpath. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5301c4f commit 1fbb57d

5 files changed

Lines changed: 276 additions & 0 deletions

File tree

featured-gradle-plugin/build.gradle.kts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,24 @@ mavenPublishing {
5454
}
5555
}
5656

57+
// A separate configuration whose resolved jars are appended to the pluginUnderTestMetadata
58+
// classpath. This makes GradleRunner.withPluginClasspath() inject them into the TestKit
59+
// subprocess, which is necessary for compileOnly dependencies (like AGP) that the plugin
60+
// needs at runtime but that java-gradle-plugin does not include from runtimeClasspath.
61+
val testPluginClasspath: Configuration by configurations.creating {
62+
isCanBeResolved = true
63+
isCanBeConsumed = false
64+
isVisible = false
65+
}
66+
67+
tasks.pluginUnderTestMetadata {
68+
pluginClasspath.from(testPluginClasspath)
69+
}
70+
5771
dependencies {
72+
// Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured
73+
// plugin can access AndroidComponentsExtension when wireProguardToVariants() is called.
74+
testPluginClasspath("com.android.tools.build:gradle:9.1.0")
5875
testImplementation(gradleTestKit())
5976
testImplementation(libs.kotlin.testJunit)
6077
testImplementation(libs.r8)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
plugins {
2+
id("com.android.application") version "9.1.0"
3+
id("dev.androidbroadcast.featured")
4+
}
5+
6+
android {
7+
namespace = "dev.androidbroadcast.featured.testapp"
8+
compileSdk = 36
9+
10+
defaultConfig {
11+
minSdk = 24
12+
targetSdk = 36
13+
}
14+
15+
buildTypes {
16+
release {
17+
isMinifyEnabled = true
18+
// Featured plugin auto-wires its proguard rules via the AGP Variant API.
19+
// A default keep file is required so R8 has something to keep.
20+
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
21+
}
22+
}
23+
}
24+
25+
featured {
26+
localFlags {
27+
boolean("dark_mode", default = false)
28+
}
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath().
2+
// No pluginManagement repositories needed — the plugins are resolved from the injected classpath.
3+
pluginManagement {
4+
repositories {
5+
google {
6+
mavenContent {
7+
includeGroupAndSubgroups("androidx")
8+
includeGroupAndSubgroups("com.android")
9+
includeGroupAndSubgroups("com.google")
10+
}
11+
}
12+
mavenCentral()
13+
gradlePluginPortal()
14+
}
15+
}
16+
17+
dependencyResolutionManagement {
18+
@Suppress("UnstableApiUsage")
19+
repositories {
20+
google {
21+
mavenContent {
22+
includeGroupAndSubgroups("androidx")
23+
includeGroupAndSubgroups("com.android")
24+
includeGroupAndSubgroups("com.google")
25+
}
26+
}
27+
mavenCentral()
28+
}
29+
}
30+
31+
rootProject.name = "android-project"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package dev.androidbroadcast.featured.gradle
2+
3+
import org.gradle.testkit.runner.GradleRunner
4+
import org.gradle.testkit.runner.TaskOutcome
5+
import org.junit.Assume.assumeTrue
6+
import org.junit.Before
7+
import org.junit.Rule
8+
import org.junit.Test
9+
import org.junit.rules.TemporaryFolder
10+
import java.io.File
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertTrue
13+
14+
/**
15+
* End-to-end integration test that verifies the Featured Gradle plugin:
16+
* 1. Generates a ProGuard file at `build/featured/proguard-featured.pro` with correct
17+
* `-assumevalues` rules for declared local flags.
18+
* 2. Auto-wires that file into the AGP release variant so the `generateProguardRules`
19+
* task participates in `assembleRelease`.
20+
*
21+
* The test uses a minimal Android application fixture copied from
22+
* `src/test/fixtures/android-project/`. It runs via Gradle TestKit with the plugin
23+
* classpath injected automatically by the `java-gradle-plugin` metadata.
24+
*
25+
* Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set — the test requires a
26+
* real Android SDK to compile the AGP-driven release build.
27+
*/
28+
class FeaturedPluginIntegrationTest {
29+
@get:Rule
30+
val tempFolder = TemporaryFolder()
31+
32+
private lateinit var projectDir: File
33+
34+
@Before
35+
fun setUp() {
36+
val sdkDir = androidSdkDir()
37+
assumeTrue(
38+
"ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests",
39+
sdkDir != null,
40+
)
41+
42+
projectDir = tempFolder.newFolder("android-project")
43+
copyFixture(projectDir)
44+
45+
// Write local.properties with sdk.dir so AGP can locate the Android SDK.
46+
projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.absolutePath}\n")
47+
}
48+
49+
// ── Tests ─────────────────────────────────────────────────────────────────
50+
51+
@Test
52+
fun `generateProguardRules task produces correct assumevalues rule for boolean local flag`() {
53+
val result =
54+
gradleRunner(projectDir)
55+
.withArguments("generateProguardRules", "--stacktrace")
56+
.build()
57+
58+
val outcome = result.task(":generateProguardRules")?.outcome
59+
assertEquals(
60+
TaskOutcome.SUCCESS,
61+
outcome,
62+
"Expected :generateProguardRules to succeed, got $outcome\n${result.output}",
63+
)
64+
65+
val proFile = projectDir.resolve("build/featured/proguard-featured.pro")
66+
assertTrue(proFile.exists(), "Expected proguard-featured.pro to be generated at ${proFile.path}")
67+
68+
val content = proFile.readText()
69+
assertContainsAssumevaluesBlock(content)
70+
}
71+
72+
@Test
73+
fun `assembleRelease wires proguard rules and completes successfully`() {
74+
val result =
75+
gradleRunner(projectDir)
76+
.withArguments("assembleRelease", "--stacktrace")
77+
.build()
78+
79+
// generateProguardRules must have run as part of the release build.
80+
val proguardOutcome = result.task(":generateProguardRules")?.outcome
81+
assertTrue(
82+
proguardOutcome == TaskOutcome.SUCCESS || proguardOutcome == TaskOutcome.UP_TO_DATE,
83+
"Expected :generateProguardRules to participate in assembleRelease, got $proguardOutcome\n${result.output}",
84+
)
85+
86+
val assembleOutcome = result.task(":assembleRelease")?.outcome
87+
assertEquals(
88+
TaskOutcome.SUCCESS,
89+
assembleOutcome,
90+
"Expected :assembleRelease to succeed, got $assembleOutcome\n${result.output}",
91+
)
92+
93+
// Verify the .pro file content is correct even after the full build.
94+
val proFile = projectDir.resolve("build/featured/proguard-featured.pro")
95+
assertTrue(proFile.exists(), "Expected proguard-featured.pro to exist after assembleRelease")
96+
assertContainsAssumevaluesBlock(proFile.readText())
97+
}
98+
99+
// ── Assertions ────────────────────────────────────────────────────────────
100+
101+
/**
102+
* Asserts that [content] contains a well-formed `-assumevalues` block targeting the
103+
* extensions class for the root module (`:`) and the `dark_mode` boolean flag.
104+
*
105+
* Expected output (from [ProguardRulesGenerator]):
106+
* ```proguard
107+
* -assumevalues class dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt {
108+
* boolean isDarkModeEnabled(dev.androidbroadcast.featured.ConfigValues) return false;
109+
* }
110+
* ```
111+
*
112+
* The root module path `:` produces the identifier `Root` via [String.modulePathToIdentifier],
113+
* so the JVM class name is `FeaturedRoot_FlagExtensionsKt`.
114+
*/
115+
private fun assertContainsAssumevaluesBlock(content: String) {
116+
assertTrue(
117+
content.contains("-assumevalues class $EXTENSIONS_FQN {"),
118+
"Expected -assumevalues block targeting $EXTENSIONS_FQN\nActual content:\n$content",
119+
)
120+
assertTrue(
121+
content.contains("boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false;"),
122+
"Expected 'boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false;' in rules\nActual content:\n$content",
123+
)
124+
}
125+
126+
// ── Helpers ───────────────────────────────────────────────────────────────
127+
128+
/** Returns the Android SDK directory from environment, or null if not set. */
129+
private fun androidSdkDir(): File? {
130+
val path =
131+
System.getenv("ANDROID_HOME")?.takeIf { it.isNotBlank() }
132+
?: System.getenv("ANDROID_SDK_ROOT")?.takeIf { it.isNotBlank() }
133+
?: return null
134+
return File(path).takeIf { it.isDirectory }
135+
}
136+
137+
/**
138+
* Copies the fixture project from `src/test/fixtures/android-project/` into [dest].
139+
*
140+
* The fixture is located relative to the plugin module's project directory, which
141+
* Gradle TestKit passes as the working directory when running tests.
142+
*/
143+
private fun copyFixture(dest: File) {
144+
val fixtureSource = fixtureDir()
145+
fixtureSource
146+
.walkTopDown()
147+
.filter { it.isFile }
148+
.forEach { file ->
149+
val relative = file.relativeTo(fixtureSource)
150+
val target = dest.resolve(relative)
151+
target.parentFile?.mkdirs()
152+
file.copyTo(target, overwrite = true)
153+
}
154+
}
155+
156+
/**
157+
* Resolves the fixture directory. The plugin module's project directory is either
158+
* injected as the `user.dir` system property by Gradle's test task, or derived
159+
* relative to this class file's location.
160+
*/
161+
private fun fixtureDir(): File {
162+
// Gradle's test task sets user.dir to the module project directory.
163+
val moduleDir = File(System.getProperty("user.dir"))
164+
val candidate = moduleDir.resolve("src/test/fixtures/android-project")
165+
require(candidate.isDirectory) {
166+
"Fixture directory not found at ${candidate.absolutePath}. " +
167+
"Expected it relative to module project dir: ${moduleDir.absolutePath}"
168+
}
169+
return candidate
170+
}
171+
172+
/**
173+
* Creates a [GradleRunner] for the fixture project.
174+
*
175+
* AGP is declared as `compileOnly` in this module — the applying build provides it at runtime.
176+
* In the TestKit subprocess, AGP is loaded by the build's own classloader when `com.android.application`
177+
* is applied from the fixture's `plugins {}` block (resolved from Google Maven). The Featured plugin
178+
* code that references [com.android.build.api.variant.AndroidComponentsExtension] is loaded in the
179+
* same classloader context, so no extra classpath injection is needed.
180+
*/
181+
private fun gradleRunner(projectDir: File): GradleRunner =
182+
GradleRunner
183+
.create()
184+
.withProjectDir(projectDir)
185+
.withPluginClasspath()
186+
.forwardOutput()
187+
188+
// ── Constants ─────────────────────────────────────────────────────────────
189+
190+
private companion object {
191+
// The fixture is a single-project (root) build.
192+
// modulePathToIdentifier(":") → "Root" → jvmFileName → "FeaturedRoot_FlagExtensionsKt"
193+
const val EXTENSIONS_FQN =
194+
"dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt"
195+
const val CONFIG_VALUES_FQN = "dev.androidbroadcast.featured.ConfigValues"
196+
const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled"
197+
}
198+
}

0 commit comments

Comments
 (0)