Testing a Gradle Plugin that interacts with Git
Some time ago, I built KMP Framework Bundler, a Gradle plugin for Kotlin Multiplatform projects that generates an XCFramework for Apple targets or a FatFramework for iOS targets and manages the publishing process to a CocoaPods repository. (Note: the plugin is currently in maintenance mode; using KMMBridge might be a better option.)
After building the Framework, the plugin handles the publishing process by interacting with a Git-based CocoaPods repository. It copies the Framework into the repository, updates the podspec file with the latest version, commits the changes, and pushes them.
Here’s a simplified example of what the plugin does:
copy {
from("$buildDir/XCFrameworks/debug")
into("$rootDir/../kmp-xcframework-dest")
}
project.exec {
workingDir = File("$rootDir/../kmp-xcframework-dest")
commandLine(
"git",
"add",
"."
).standardOutput
}
val dateFormatter = SimpleDateFormat("dd/MM/yyyy - HH:mm", Locale.getDefault())
project.exec {
workingDir = File("$rootDir/../kmp-xcframework-dest")
commandLine(
"git",
"commit",
"-m",
"\"New dev release: ${libVersionName}-${dateFormatter.format(Date())}\""
).standardOutput
}
project.exec {
workingDir = File("$rootDir/../kmp-xcframework-dest")
commandLine("git", "push", "origin", "develop").standardOutput
}
More details about KMP Framework Bundler are available in a previous article.
Automated testing
Before every release, I manually tested the entire publishing process. This involved setting up a simple local Kotlin Multiplatform project, configuring local and remote CocoaPods repositories, running the plugin, and verifying that everything worked correctly.
This manual process was time-consuming, so I started exploring Gradle TestKit to automate it. Gradle TestKit allows you to programmatically execute Gradle builds and inspect the results.
However, testing Gradle plugins that interact with Git can be tricky, especially when they involve committing changes and pushing to remote repositories. In this article, I’ll show how I was able to test my plugin using Gradle TestKit and Git bare repositories, eliminating the need for manual testing and actual remote repositories.
Testing project structure
With GradleTestKit, it’s possible to create a test project inside the test resources and run the plugin on it:
.
└── kmp-framework-bundler
└── src
├── main
└── test
├── kotlin
└── resources
└── test-project
├── build.gradle.kts
├── gradle.properties
├── settings.gradle
└── src
└── commonMain
└── ...
To test the publishing process, both local and remote Git repositories are needed. Using an actual remote repository would be unreliable and hard to maintain, so the tests create a local bare repository to simulate the remote.
A Git bare repository is a special type of repository that doesn’t have a working directory. It contains only the Git database (i.e., the contents of the .git folder) without any checked-out files.
In contrast, a regular Git repository created with git init
includes both the Git database and a working directory. A bare repository, created with git init --bare
, contains only the database, making it ideal for simulating a remote server in tests.
Testing infrastructure
A base class sets up the testing environment, so the test classes can focus on assertions. Here’s the whole class, which we’ll break down below:
abstract class BasePublishTest(
// Enum that specifies whether to test FatFramework or XCFramework publishing
private val frameworkType: FrameworkType,
) {
lateinit var testDestFolder: File
lateinit var podSpecFile: File
lateinit var testProject: File
private lateinit var buildGradleFile: File
private lateinit var remoteDestFolder: File
private lateinit var tempBuildGradleFile: File
@Before
fun setup() {
// Setup test environment
testProject = File("src/test/resources/test-project")
buildGradleFile = File("src/test/resources/test-project/build.gradle.kts")
tempBuildGradleFile = File("src/test/resources/test-project/build.gradle.kts.new")
buildGradleFile.copyTo(tempBuildGradleFile)
val currentPath = Paths.get("").toAbsolutePath().toString()
testDestFolder = File("$currentPath/../test-dest")
testDestFolder.mkdirs()
remoteDestFolder = File("$currentPath/../remote-dest")
remoteDestFolder.mkdirs()
buildGradleFile.appendText(getGradleFile())
// Initialize local Git repository
testDestFolder.runBashCommand("git", "init")
testDestFolder.runBashCommand("git", "branch", "-m", "main")
// Create and commit podspec file
podSpecFile = File("${testDestFolder.path}/LibraryName.podspec")
podSpecFile.writeText(getPodSpec())
testDestFolder.runBashCommand("git", "add", ".")
testDestFolder.runBashCommand("git", "commit", "-m", "\"First commit\"")
// Initialize bare repository as remote
remoteDestFolder.runBashCommand("git", "init", "--bare")
// Connect local repo to remote and push
testDestFolder.runBashCommand("git", "remote", "add", "origin", remoteDestFolder.path)
testDestFolder.runBashCommand("git", "push", "origin", "--all")
// Create develop branch for testing
testDestFolder.runBashCommand("git", "checkout", "-b", "develop")
}
@After
fun cleanUp() {
// Clean up test environment
buildGradleFile.deleteRecursively()
tempBuildGradleFile.renameTo(buildGradleFile)
testDestFolder.deleteRecursively()
remoteDestFolder.deleteRecursively()
File("${testProject.path}/build").deleteRecursively()
}
// Helper methods
private fun getGradleFile(): String = when (frameworkType) {
FrameworkType.FAT_FRAMEWORK -> fatFrameworkGradleFile
FrameworkType.XC_FRAMEWORK -> xcFrameworkGradleFile
}
private fun getPodSpec(): String = when (frameworkType) {
FrameworkType.XC_FRAMEWORK -> xcFrameworkPodSpec
FrameworkType.FAT_FRAMEWORK -> fatFrameworkPodSpec
}
}
1. Setting Up the Test Project
The first step involves accessing the test project and backing up its build.gradle.kts
file to restore it when the test is done quickly.
testProject = File("src/test/resources/test-project")
buildGradleFile = File("src/test/resources/test-project/build.gradle.kts")
tempBuildGradleFile = File("src/test/resources/test-project/build.gradle.kts.new")
buildGradleFile.copyTo(tempBuildGradleFile)
2. Creating Local and remote directories
Two folders are created:
testDestFolder
: acts as the local repository where the Framework is published.remoteDestFolder
: a bare repository that simulates the remote server.
val currentPath = Paths.get("").toAbsolutePath().toString()
testDestFolder = File("$currentPath/../test-dest")
testDestFolder.mkdirs()
remoteDestFolder = File("$currentPath/../remote-dest")
remoteDestFolder.mkdirs()
3. Configuring the build file
The test project provides a build.gradle.kts
file with a basic setup. The file needs to be customized based on the type of Framework that the test needs to validate:
buildGradleFile.appendText(getGradleFile())
4. Setting Up the Local Git Repository
A new Git repository is initialized in the test destination folder
testDestFolder.runBashCommand("git", "init")
testDestFolder.runBashCommand("git", "branch", "-m", "main")
and a PodSpec
file is committed to the repository
podSpecFile = File("${testDestFolder.path}/LibraryName.podspec")
podSpecFile.writeText(getPodSpec())
testDestFolder.runBashCommand("git", "add", ".")
testDestFolder.runBashCommand("git", "commit", "-m", "\"First commit\"")
5. Creating the Bare Repository
This is where the magic happens! A bare Git repository is created in the remote destination folder. This repository will act as a “remote” server without requiring network access.
remoteDestFolder.runBashCommand("git", "init", "--bare")
After configuring the “remote” repository, the initial content can be pushed
testDestFolder.runBashCommand("git", "remote", "add", "origin", remoteDestFolder.path)
testDestFolder.runBashCommand("git", "push", "origin", "--all")
6. Creating a Development Branch
The final step is creating a development branch that will be used to test the debug framework publishing.
testDestFolder.runBashCommand("git", "checkout", "-b", "develop")
7. Cleaning Up After Tests
After running the test, all temporary files and directories are cleaned up, and the original build file is restored.
@After
fun cleanUp() {
buildGradleFile.deleteRecursively()
tempBuildGradleFile.renameTo(buildGradleFile)
testDestFolder.deleteRecursively()
remoteDestFolder.deleteRecursively()
File("${testProject.path}/build").deleteRecursively()
}
Testing the plugin
With the setup complete, the publishing pipeline can be fully tested:
class XCFrameworkTasksPublishTests : BasePublishTest(frameworkType = FrameworkType.XC_FRAMEWORK) {
@Test
fun `When running the publish debug fat framework task, in the destination, the version number is updated in the pod spec, the branch is develop and the commit message is correct`() {
GradleRunner.create()
.withProjectDir(this)
.withArguments(PublishDebugXCFrameworkTask.NAME, "--stacktrace")
.forwardOutput()
.build()
// version on pod spec
assertTrue(podSpecFile.getPlainText().contains(POD_SPEC_VERSION_NUMBER))
// branch name
val branchOutput = testDestFolder.runBashCommandAndGetOutput("git", "branch", "--list", "develop")
assertTrue(branchOutput.contains("develop"))
// commit message
val commitOutput = testDestFolder.runBashCommandAndGetOutput("git", "log", "-1")
assertTrue(commitOutput.contains("New debug release: $FRAMEWORK_VERSION_NUMBER -"))
}
}
runBashCommandAndGetOutput
andrunBashCommand
are custom helpers that use Gradle APIs. You can find the implementation in the project repository.
Conclusions
By combining Gradle TestKit with a Git bare repository, I could fully automate testing for the KMP Framework Bundler plugin, including the entire publishing workflow. This approach eliminates the need to set up complex local environments.
This pattern can easily be adapted for testing other plugins that interact with version control, providing a reliable and automated way to ensure correctness in real-world scenarios.