Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide junit extension #2

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ plugins {

dependencies {
implementation(libs.kotlinGradlePlugin)
implementation(libs.deltaCoveragePlugin)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import io.github.surpsg.deltacoverage.CoverageEngine

plugins {
base
id("io.github.surpsg.delta-coverage")
}

deltaCoverageReport {
coverage.engine = CoverageEngine.INTELLIJ

diffSource.byGit {
diffBase = project.properties["diffBase"]?.toString() ?: "refs/remotes/origin/main"
useNativeGit = true
}

reports {
html = true
xml = true
console = true
}

violationRules.failIfCoverageLessThan(0.9)
}

tasks.named("gitDiff") {
outputs.upToDateWhen { false }
}
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id("buildlogic.java-common-conventions")
id("buildlogic.delta-coverage-conventions")
}

allprojects {
Expand Down
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
[versions]
kotlinVersion = "1.9.22"
junitVersion = "5.10.0"
deltaCoverageVer = "2.3.0"

[libraries]
# Kotlin
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinVersion" }
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinVersion" }
deltaCoveragePlugin = { module = "io.github.surpsg:delta-coverage-gradle", version.ref = "deltaCoverageVer" }

# Testing
junit = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitVersion" }

[plugins]
6 changes: 6 additions & 0 deletions lib/build.gradle.kts → junit-engine/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ plugins {
id("buildlogic.kotlin-common-conventions")
}

dependencies {
implementation(libs.junit)
implementation(libs.kotlinReflect)
implementation(gradleTestKit())
}

@Suppress("UnstableApiUsage")
testing {
suites {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.github.gwkit.gradleprobe.gradle.junit
import io.github.gwkit.gradleprobe.gradle.resources.RestorableFile
import io.github.gwkit.gradleprobe.gradle.resources.copyDirFromResources
import io.github.gwkit.gradleprobe.gradle.resources.toUnixAbsolutePath
import io.github.gwkit.gradleprobe.gradle.runner.buildGradleRunner
import io.github.gwkit.gradleprobe.lib.reflect.injectProperty
import org.gradle.testkit.runner.GradleRunner
import org.gradle.util.GradleVersion as GradleToolVersion
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.TestInstancePostProcessor
import java.io.File
import java.nio.file.Files
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation

class GradlePluginTestExtension : TestInstancePostProcessor {

override fun postProcessTestInstance(testInstance: Any, context: ExtensionContext) {
val testClass: KClass<out Any> = testInstance::class

if (testClass.hasAnnotation<Nested>()) {
return
}

val gradleRunnerTest: GradleRunnerTest = testClass.findAnnotation()
?: error("Test class ${testInstance::class.qualifiedName} must be annotated with @GradleRunnerTest")

val gradleVersion: GradleVersion? = testClass.findAnnotation()

val rootProjectDir: File = copyResourceProjectToTempDir(
testClass,
gradleRunnerTest
)

with(testInstance) {
injectProperty<RootProjectDir, File>(rootProjectDir)
injectProperty<GradleRunnerInstance, GradleRunner>(
buildGradleRunner(
rootProjectDir,
gradleVersion?.version ?: GradleToolVersion.current().version
)
)
injectProperty<ProjectFile, RestorableFile> {
val tempTestFile: File = Files.createTempDirectory(TEST_DIR_PREFIX).toFile()
val fileToBeRestored: File = resolveExistingFile(rootProjectDir, relativePath)
val originCopy: File = tempTestFile.resolve(UUID.randomUUID().toString())
fileToBeRestored.copyTo(originCopy)
RestorableFile(originFileCopy = originCopy, file = fileToBeRestored)
}
injectProperty<ProjectFile, File> {
resolveExistingFile(rootProjectDir, relativePath)
}
injectProperty<ProjectFile, String> {
resolveExistingFile(rootProjectDir, relativePath).toUnixAbsolutePath()
}
}
}

private fun copyResourceProjectToTempDir(
testClass: KClass<out Any>,
gradleRunnerTest: GradleRunnerTest,
): File {
val tempTestFile: File = Files.createTempDirectory(TEST_DIR_PREFIX).toFile()

return tempTestFile.copyDirFromResources(
testClass,
gradleRunnerTest.resourceProjectDir
)
}

private fun resolveExistingFile(rootProjectDir: File, relativePath: String): File {
return rootProjectDir.resolve(relativePath)
.takeIf { it.exists() }
?: error("File $relativePath not found")
}

companion object {
private const val TEST_DIR_PREFIX = "gradle-runner-test"
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.github.gwkit.gradleprobe.gradle.junit

/**
* Prepares a test class for running a Gradle plugin test.
* The extension finds the test project in resources and copies it to a temporary directory.
*
* @param resourceProjectDir The relative path to the test project located in test resources.
*
* @see GradlePluginTestExtension
*/
@MustBeDocumented
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class GradleRunnerTest(
val resourceProjectDir: String,
)

/**
* Specifies the Gradle version to use for the test.
* The extension will use the specified Gradle version to run the test.
*
* @param version The Gradle version to use.
*
* @see GradlePluginTestExtension
*/
@MustBeDocumented
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class GradleVersion(
val version: String
)

/**
* Injects a GradleRunner instance into test class property.
* The property must be of type GradleRunner and must be lateinit.
* ```
* @GradlePluginTest("testProject")
* class MyTest {
*
* @GradleRunnerInstance
* lateinit var gradleRunner: GradleRunner
*
* }
* ```
*/
@MustBeDocumented
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class GradleRunnerInstance

/**
* Injects the root project directory into test class property.
* The property must be of type File and must be lateinit.
* ```
* @GradlePluginTest("testProject")
* class MyTest {
*
* @RootProjectDir
* lateinit var rootProjectDir: File
*
* }
* ```
*/
@MustBeDocumented
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class RootProjectDir

/**
* Resolves and injects a project file into test class property. The file must exist.
* The property must be of type [java.io.File] or [String] and must be lateinit.
* ```
* @GradlePluginTest("testProject")
* class MyTest {
*
* @ProjectFile("build.gradle.kts")
* lateinit var buildFile: File
*
* @ProjectFile("build.gradle.kts")
* lateinit var buildFilePath: String
*
* }
* ```
* @param relativePath The relative path to the project file.
* Useful when the test class has `TestInstance.Lifecycle.PER_CLASS` lifecycle.
*/
@MustBeDocumented
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class ProjectFile(
val relativePath: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.github.gwkit.gradleprobe.gradle.resources

import java.io.File
import java.net.URL
import kotlin.reflect.KClass

internal fun KClass<out Any>.getResourceFile(filePath: String): File {
val url: URL = java.classLoader
.getResource(filePath)
?: error("Resource file $filePath not found")
return File(url.file)
}

internal fun File.copyDirFromResources(
sourceClass: KClass<out Any>,
dirToCopy: String,
destDir: String = dirToCopy,
): File {
val target: File = resolve(destDir)
sourceClass.getResourceFile(dirToCopy)
.copyRecursively(target, true)
return target
}

internal fun File.toUnixAbsolutePath(): String = absolutePath.replace("\\", "/")
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.github.gwkit.gradleprobe.gradle.resources

import java.io.File
import java.io.IOException

/**
* Represents a file that can be restored to its original content.
* The file is created by copying the content of the origin file.
*
* @param originFileCopy The origin file copy.
* @param file The file to restore.
*
*/
class RestorableFile(private val originFileCopy: File, val file: File) {

@Throws(IOException::class)
fun restoreOriginContent() {
originFileCopy.copyTo(file, overwrite = true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.github.gwkit.gradleprobe.gradle.runner

import org.gradle.testkit.runner.GradleRunner
import org.gradle.util.GradleVersion
import java.io.File
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString

val GRADLE_HOME: String
get() {
val userHome: String = System.getProperty("user.home") ?: error("Cannot obtain 'user.home'.")
return Path(userHome, ".gradle").absolutePathString()
}

fun buildGradleRunner(
projectRoot: File,
gradleVersion: String = GradleVersion.current().version
): GradleRunner {
return GradleRunner.create()
.withPluginClasspath()
.withProjectDir(projectRoot)
.withTestKitDir(
projectRoot.resolve(GRADLE_HOME).apply { mkdirs() }
)
.withGradleVersion(gradleVersion)
.apply {
// gradle testkit jacoco support
javaClass.classLoader.getResourceAsStream("testkit-gradle.properties")?.use { inputStream ->
File(projectDir, "gradle.properties").outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.github.gwkit.gradleprobe.lib.reflect

import kotlin.reflect.KMutableProperty
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible

internal inline fun <reified A : Annotation, reified V> Any.injectProperty(
valueToInject: V,
) {
val thisInstance = this
thisInstance::class
.memberProperties
.asSequence()
.filter { property -> property.hasAnnotation<A>() }
.filter { property -> property.returnType.classifier == V::class }
.filter { property -> property.isLateinit }
.onEach { property -> property.isAccessible = true }
.mapNotNull { property -> property as? KMutableProperty<*> }
.forEach { property -> property.setter.call(thisInstance, valueToInject) }
}

internal inline fun <reified A : Annotation, reified V> Any.injectProperty(
crossinline valueProvider: A.() -> V,
) {
val thisInstance = this
thisInstance::class
.memberProperties
.asSequence()
.mapNotNull { property ->
property.findAnnotation<A>()?.let { annotation -> annotation to property }
}
.filter { (_, property) -> property.returnType.classifier == V::class }
.filter { (_, property) -> property.isLateinit }
.onEach { (_, property) -> property.isAccessible = true }
.mapNotNull { (annotation, property) ->
(property as? KMutableProperty<*>)?.let { mutableProperty ->
annotation.valueProvider() to mutableProperty
}
}
.forEach { (valueToInject, property) -> property.setter.call(thisInstance, valueToInject) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.github.gwkit.gradleprobe.gradle.junit

class GradlePluginTestExtension : TestInstancePostProcessor {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.github.gwkit.gradleprobe.gradle.junit


@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class GradlePluginTest(
// resource project directory
// kts/groovy (is needed?)
// gradle version
val resourceProjectDir: String,
val gradleVersion: String = DEFAULT_GRADLE_VERSION,
) {
companion object {
const val DEFAULT_GRADLE_VERSION = "8.7.0"
}
}
Loading
Loading