diff --git a/CHANGELOG.md b/CHANGELOG.md index 174ee30..eebcf11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] [Unreleased]: https://github.com/cashapp/burst/compare/0.6.0...HEAD +**Added** + + * Basic support for JUnit 5. Burst doesn't support JUnit 5 tests that populate parameters from extensions. + ## [2.0.0] *(2024-10-30)* [2.0.0]: https://github.com/cashapp/burst/releases/tag/2.0.0 diff --git a/burst-gradle-plugin/src/test/kotlin/app/cash/burst/gradle/BurstGradlePluginTest.kt b/burst-gradle-plugin/src/test/kotlin/app/cash/burst/gradle/BurstGradlePluginTest.kt index 736b1e2..819dadf 100644 --- a/burst-gradle-plugin/src/test/kotlin/app/cash/burst/gradle/BurstGradlePluginTest.kt +++ b/burst-gradle-plugin/src/test/kotlin/app/cash/burst/gradle/BurstGradlePluginTest.kt @@ -261,6 +261,31 @@ class BurstGradlePluginTest { } } + @Test + fun junit5() { + val projectDir = File("src/test/projects/junit5") + + val taskName = ":lib:test" + val result = createRunner(projectDir, "clean", taskName).build() + assertThat(result.task(taskName)!!.outcome).isIn(*SUCCESS_OUTCOMES) + + val testResults = projectDir.resolve("lib/build/test-results") + + with(readTestSuite(testResults.resolve("test/TEST-CoffeeTest_Regular.xml"))) { + assertThat(testCases.map { it.name }).containsExactlyInAnyOrder( + "kotlinTestTest_Milk()", + "kotlinTestTest_None()", + "kotlinTestTest_Oat()", + "orgJunitJupiterApiTest_Milk()", + "orgJunitJupiterApiTest_None()", + "orgJunitJupiterApiTest_Oat()", + "orgJunitTest_Milk", + "orgJunitTest_None", + "orgJunitTest_Oat", + ) + } + } + private fun createRunner( projectDir: File, vararg taskNames: String, diff --git a/burst-gradle-plugin/src/test/projects/junit5/build.gradle.kts b/burst-gradle-plugin/src/test/projects/junit5/build.gradle.kts new file mode 100644 index 0000000..738b082 --- /dev/null +++ b/burst-gradle-plugin/src/test/projects/junit5/build.gradle.kts @@ -0,0 +1,41 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +buildscript { + repositories { + maven { + url = file("$rootDir/../../../../../build/testMaven").toURI() + } + mavenCentral() + google() + } + dependencies { + classpath("app.cash.burst:burst-gradle-plugin:${project.property("burstVersion")}") + classpath(libs.kotlin.gradlePlugin) + } +} + +allprojects { + repositories { + maven { + url = file("$rootDir/../../../../../build/testMaven").toURI() + } + mavenCentral() + google() + } + + tasks.withType(JavaCompile::class.java).configureEach { + sourceCompatibility = "1.8" + targetCompatibility = "1.8" + } + + tasks.withType(KotlinJvmCompile::class.java).configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + + tasks.withType { + useJUnitPlatform() + } +} diff --git a/burst-gradle-plugin/src/test/projects/junit5/lib/build.gradle.kts b/burst-gradle-plugin/src/test/projects/junit5/lib/build.gradle.kts new file mode 100644 index 0000000..afcc227 --- /dev/null +++ b/burst-gradle-plugin/src/test/projects/junit5/lib/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + kotlin("jvm") + id("app.cash.burst") +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.junit) + testRuntimeOnly(libs.junit.vintage.engine) +} diff --git a/burst-gradle-plugin/src/test/projects/junit5/lib/src/test/kotlin/CoffeeTest.kt b/burst-gradle-plugin/src/test/projects/junit5/lib/src/test/kotlin/CoffeeTest.kt new file mode 100644 index 0000000..4290954 --- /dev/null +++ b/burst-gradle-plugin/src/test/projects/junit5/lib/src/test/kotlin/CoffeeTest.kt @@ -0,0 +1,32 @@ +import app.cash.burst.Burst +import kotlin.test.BeforeTest +import kotlin.test.Test + +@Burst +class CoffeeTest( + private val espresso: Espresso, +) { + @BeforeTest + fun setUp() { + println("set up $espresso") + } + + @kotlin.test.Test + fun kotlinTestTest(dairy: Dairy) { + println("@kotlin.test.Test running $espresso $dairy") + } + + @org.junit.Test + fun orgJunitTest(dairy: Dairy) { + println("@org.junit.Test running $espresso $dairy") + } + + @org.junit.jupiter.api.Test + fun orgJunitJupiterApiTest(dairy: Dairy) { + println("@org.junit.jupiter.api.Test running $espresso $dairy") + } +} + +enum class Espresso { Decaf, Regular, Double } + +enum class Dairy { None, Milk, Oat } diff --git a/burst-gradle-plugin/src/test/projects/junit5/settings.gradle.kts b/burst-gradle-plugin/src/test/projects/junit5/settings.gradle.kts new file mode 100644 index 0000000..78eeed4 --- /dev/null +++ b/burst-gradle-plugin/src/test/projects/junit5/settings.gradle.kts @@ -0,0 +1,9 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../../../../../gradle/libs.versions.toml")) + } + } +} + +include(":lib") diff --git a/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstApis.kt b/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstApis.kt index 7e5615a..212feb1 100644 --- a/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstApis.kt +++ b/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstApis.kt @@ -17,15 +17,25 @@ package app.cash.burst.kotlin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.ir.declarations.IrAnnotationContainer +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.symbols.IrClassSymbol import org.jetbrains.kotlin.ir.symbols.IrFunctionSymbol +import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.util.hasAnnotation /** Looks up APIs used by the code rewriters. */ internal class BurstApis private constructor( pluginContext: IrPluginContext, - testPackage: FqPackageName, + private val testClassSymbols: List, ) { + val burstValues: IrFunctionSymbol = pluginContext.referenceFunctions(burstValuesId).single() + + fun findTestAnnotation(function: IrSimpleFunction): IrClassSymbol? { + return function.annotations + .mapNotNull { it.type.classOrNull } + .firstOrNull { it in testClassSymbols } + } + companion object { fun maybeCreate(pluginContext: IrPluginContext): BurstApis? { // If we don't have @Burst, we don't have the runtime. Abort! @@ -33,21 +43,20 @@ internal class BurstApis private constructor( return null } - if (pluginContext.referenceClass(junitTestClassId) != null) { - return BurstApis(pluginContext, junitPackage) - } + val testClassSymbols = listOfNotNull( + pluginContext.referenceClass(junit5TestClassId), + pluginContext.referenceClass(junitTestClassId), + pluginContext.referenceClass(kotlinTestClassId), + ) - if (pluginContext.referenceClass(kotlinTestClassId) != null) { - return BurstApis(pluginContext, kotlinTestPackage) + // No @Test annotations? No Burst. + if (testClassSymbols.isEmpty()) { + return null } - // No kotlin.test and no org.junit. No Burst for you. - return null + return BurstApis(pluginContext, testClassSymbols) } } - - val testClassSymbol: IrClassSymbol = pluginContext.referenceClass(testPackage.classId("Test"))!! - val burstValues: IrFunctionSymbol = pluginContext.referenceFunctions(burstValuesId).single() } private val burstFqPackage = FqPackageName("app.cash.burst") @@ -56,11 +65,10 @@ private val burstValuesId = burstFqPackage.callableId("burstValues") private val junitPackage = FqPackageName("org.junit") private val junitTestClassId = junitPackage.classId("Test") +private val junit5Package = FqPackageName("org.junit.jupiter.api") +private val junit5TestClassId = junit5Package.classId("Test") private val kotlinTestPackage = FqPackageName("kotlin.test") private val kotlinTestClassId = kotlinTestPackage.classId("Test") -internal val IrAnnotationContainer.hasAtTest: Boolean - get() = hasAnnotation(junitTestClassId) || hasAnnotation(kotlinTestClassId) - internal val IrAnnotationContainer.hasAtBurst: Boolean get() = hasAnnotation(burstAnnotationId) diff --git a/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstIrGenerationExtension.kt b/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstIrGenerationExtension.kt index 56ee81a..4ba2bc0 100644 --- a/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstIrGenerationExtension.kt +++ b/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/BurstIrGenerationExtension.kt @@ -61,7 +61,7 @@ class BurstIrGenerationExtension( val originalFunctions = classDeclaration.functions.toList() for (function in originalFunctions) { - if (!function.hasAtTest) continue + val testAnnotationClassSymbol = burstApis.findTestAnnotation(function) ?: continue if (!classHasAtBurst && !function.hasAtBurst) continue try { @@ -70,6 +70,7 @@ class BurstIrGenerationExtension( burstApis = burstApis, originalParent = classDeclaration, original = function, + testAnnotationClassSymbol = testAnnotationClassSymbol, ) specializer.generateSpecializations() } catch (e: BurstCompilationException) { diff --git a/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/FunctionSpecializer.kt b/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/FunctionSpecializer.kt index 01ee258..6136cea 100644 --- a/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/FunctionSpecializer.kt +++ b/burst-kotlin-plugin/src/main/kotlin/app/cash/burst/kotlin/FunctionSpecializer.kt @@ -25,9 +25,9 @@ import org.jetbrains.kotlin.ir.declarations.IrClass import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.symbols.IrClassSymbol import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI -import org.jetbrains.kotlin.ir.types.classFqName -import org.jetbrains.kotlin.ir.types.starProjectedType +import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.util.patchDeclarationParents import org.jetbrains.kotlin.name.Name @@ -63,6 +63,7 @@ internal class FunctionSpecializer( private val burstApis: BurstApis, private val originalParent: IrClass, private val original: IrSimpleFunction, + private val testAnnotationClassSymbol: IrClassSymbol, ) { fun generateSpecializations() { val valueParameters = original.valueParameters @@ -84,7 +85,7 @@ internal class FunctionSpecializer( // Drop `@Test` from the original's annotations. original.annotations = original.annotations.filter { - it.type.classFqName != burstApis.testClassSymbol.starProjectedType.classFqName + it.type.classOrNull != testAnnotationClassSymbol } // Add new declarations. @@ -112,7 +113,7 @@ internal class FunctionSpecializer( } } - result.annotations += burstApis.testClassSymbol.asAnnotation() + result.annotations += testAnnotationClassSymbol.asAnnotation() result.irFunctionBody( context = pluginContext, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b18c0ec..62229e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ binary-compatibility-validator-gradle-plugin = { module = "org.jetbrains.kotlinx dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "1.9.20" } google-ksp = "com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.21-1.0.26" junit = { module = "junit:junit", version = "4.13.2" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version = "5.11.3" } kotlin-compile-testing = { module = "dev.zacsweers.kctfork:core", version = "0.5.1" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" }