diff --git a/build.gradle b/build.gradle index d8581a04..44e296e8 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,8 @@ plugins { id 'com.android.library' version libs.versions.agp apply false id 'org.jetbrains.kotlin.multiplatform' version libs.versions.kotlin apply false id 'org.jetbrains.compose' version libs.versions.composeMultiplatform apply false + id 'de.mannodermaus.android-junit5' version libs.versions.junit5Android apply false + id 'tech.apter.junit5.jupiter.robolectric-extension-gradle-plugin' version libs.versions.junit5Robolectric apply false id 'org.jetbrains.kotlin.android' version libs.versions.kotlin apply false id "com.vanniktech.maven.publish" version libs.versions.mavenPublish apply false diff --git a/docs/roborazzi-docs.tree b/docs/roborazzi-docs.tree index 11e2edf8..1e810c25 100644 --- a/docs/roborazzi-docs.tree +++ b/docs/roborazzi-docs.tree @@ -11,6 +11,7 @@ + \ No newline at end of file diff --git a/docs/topics/junit5.md b/docs/topics/junit5.md new file mode 100644 index 00000000..c66b28f8 --- /dev/null +++ b/docs/topics/junit5.md @@ -0,0 +1,87 @@ +# JUnit 5 support + +Roborazzi supports the execution of screenshot tests with JUnit 5, +powered by the combined forces of the [Android JUnit 5](https://github.com/mannodermaus/android-junit5) plugin +and the [JUnit 5 Robolectric Extension](https://github.com/apter-tech/junit5-robolectric-extension). + +### Setup + +To get started with Roborazzi for JUnit 5, make sure to set up your project for the new testing framework first +and add the dependencies for JUnit Jupiter and the Robolectric extension to your project (check the readme files +of either project linked above to find the latest version). Then, add the `roborazzi-junit5` dependency +next to the existing Roborazzi dependency. The complete build script setup looks something like this: + +```kotlin +// Root moduls's build.gradle.kts: +plugins { + id("io.github.takahirom.roborazzi") version "$roborazziVersion" apply false + id("de.mannodermaus.android-junit5") version "$androidJUnit5Version" apply false + id("tech.apter.junit5.jupiter.robolectric-extension-gradle-plugin") version "$robolectricExtensionVersion" apply false +} +``` + +```kotlin +// App module's build.gradle.kts: +plugins { + id("de.mannodermaus.android-junit5") + id("tech.apter.junit5.jupiter.robolectric-extension-gradle-plugin") +} + +dependencies { + testImplementation("org.robolectric:robolectric:$robolectricVersion") + testImplementation("io.github.takahirom.roborazzi:$roborazziVersion") + testImplementation("io.github.takahirom.roborazzi-junit5:$roborazziVersion") + + testImplementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion") +} +``` + +You are now ready to write a JUnit 5 screenshot test with Roborazzi. + +### Write a test + +JUnit 5 does not have a concept of `@Rule`. Instead, extension points to the test framework have to be registered, +and Roborazzi is no exception to this. Add the `@ExtendWith` annotation to the test class and insert both the +extension for Robolectric (from the third-party dependency defined above) and Roborazzi (from `roborazzi-junit5`). +If you also have JUnit 4 on the classpath, make sure to import the correct `@Test` annotation (`org.junit.jupiter.api.Test` +instead of `org.junit.Test`): + +```kotlin +// MyTest.kt: +@ExtendWith(RobolectricExtension::class, RoborazziExtension::class) +class MyTest { + @Test + fun test() { + // Your ordinary Roborazzi setup here, for example: + ActivityScenario.launch(MainActivity::class.java) + onView(isRoot()).captureRoboImage() + } +} +``` + +### Automatic Extension Registration + +You may tell JUnit 5 to automatically attach the `RoborazziExtension` to applicable test classes, +minimizing the redundancy of having to add `@ExtendWith(RoborazziExtension::class)` to every class. +This is done via a process called [Automatic Extension Registration](https://junit.org/junit5/docs/current/user-guide/#extensions-registration-automatic) and must be enabled in the build file. +Be aware that you still need `ExtendWith` for the `RobolectricExtension`, since it is not eligible for +automatic registration. Think of it as the JUnit 5 equivalent of `@RunWith(RobolectricTestRunner::class)`: + +```kotlin +// App module's build.gradle.kts: +junitPlatform { + configurationParameter( + "junit.jupiter.extensions.autodetection.enabled", + "true" + ) +} +``` + +```kotlin +// MyTest.kt: +@ExtendWith(RobolectricExtension::class) +class MyTest { + // ... +} +``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6302de32..4c3fedba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,8 @@ kotlin = "1.9.21" mavenPublish = "0.25.3" composeCompiler = "1.5.6" composeMultiplatform = "1.6.2" +junit5Android = "1.10.0.0" +junit5Robolectric = "0.7.0" robolectric = "4.12.2" robolectric-android-all = "Q-robolectric-5415296" @@ -31,11 +33,13 @@ androidx-lifecycle = "2.6.1" androidx-navigation = "2.7.7" androidx-test-espresso-core = "3.5.1" androidx-test-ext-junit = "1.1.5" +androidx-test-runner = "1.5.2" kim = "0.17.7" dropbox-differ = "0.0.2" google-android-material = "1.5.0" junit = "4.13.2" +junit5 = "5.10.2" ktor-serialization-kotlinx-xml = "2.3.0" kotlinx-serialization = "1.6.3" squareup-okhttp = "5.0.0-alpha.11" @@ -50,6 +54,9 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit" } +android-junit5-plugin = { module = "de.mannodermaus.gradle.plugins:android-junit5", version.ref = "junit5Android" } +robolectric-junit5-plugin = { module = "tech.apter.junit5.jupiter:robolectric-extension-gradle-plugin", version.ref = "junit5Robolectric" } + androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } @@ -72,6 +79,7 @@ androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso-core" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } kim = { module = "com.ashampoo:kim", version.ref = "kim" } android-tools-build-gradle = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -82,6 +90,10 @@ compose-ui-test-junit4-desktop = { module = "org.jetbrains.compose.ui:ui-test-ju dropbox-differ = { module = "com.dropbox.differ:differ", version.ref = "dropbox-differ" } google-android-material = { module = "com.google.android.material:material", version.ref = "google-android-material" } junit = { module = "junit:junit", version.ref = "junit" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-serialization-kotlinx-xml = { module = "io.ktor:ktor-serialization-kotlinx-xml", version.ref = "ktor-serialization-kotlinx-xml" } squareup-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "squareup-okhttp" } diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt index 4abb6f65..bee5bf83 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt @@ -1,10 +1,8 @@ package com.github.takahirom.roborazzi -import org.junit.Test import org.junit.runner.Description import java.io.File - object DefaultFileNameGenerator { enum class DefaultNamingStrategy(val optionName: String) { TestPackageAndClassAndMethod("testPackageAndClassAndMethod"), @@ -22,6 +20,9 @@ object DefaultFileNameGenerator { private val defaultNamingStrategy by lazy { roborazziDefaultNamingStrategy() } + private val testNameExtractionStrategies by lazy { + roborazziTestNameExtractionStrategies() + } @InternalRoborazziApi fun generateFilePath(extension: String): String { @@ -38,59 +39,31 @@ object DefaultFileNameGenerator { return when (roborazziRecordFilePathStrategy()) { RoborazziRecordFilePathStrategy.RelativePathFromCurrentDirectory -> { val dir = roborazziContext.outputDirectory - "$dir/${generateCountableOutputNameWithStacktrace()}.$extension" + "$dir/${generateCountableOutputNameWithStrategies()}.$extension" } RoborazziRecordFilePathStrategy.RelativePathFromRoborazziContextOutputDirectory -> { // The directory is specified by [fileWithRecordFilePathStrategy(filePath)] - "${generateCountableOutputNameWithStacktrace()}.$extension" + "${generateCountableOutputNameWithStrategies()}.$extension" } } } - val jupiterTestAnnotationOrNull = try { - Class.forName("org.junit.jupiter.api.Test") as Class - } catch (e: ClassNotFoundException) { - null - } - - private fun generateCountableOutputNameWithStacktrace(): String { + private fun generateCountableOutputNameWithStrategies(): String { val testName = - generateOutputNameWithStackTrace() + generateOutputNameWithStrategies() return countableOutputName(testName) } - internal fun generateOutputNameWithStackTrace(): String { - // Find test method name - val allStackTraces = Thread.getAllStackTraces() - val filteredTracces = allStackTraces - // The Thread Name is come from here - // https://github.com/robolectric/robolectric/blob/40832ada4a0651ecbb0151ebed2c99e9d1d71032/robolectric/src/main/java/org/robolectric/internal/AndroidSandbox.java#L67 - .filterKeys { - it.name.contains("Main Thread") - || it.name.contains("Test worker") - } - val traceElements = filteredTracces - .flatMap { it.value.toList() } - val stackTraceElement = traceElements - .firstOrNull { - try { - val method = Class.forName(it.className).getMethod(it.methodName) - method - .getAnnotation(Test::class.java) != null || - (jupiterTestAnnotationOrNull != null && (method - .getAnnotation(jupiterTestAnnotationOrNull) as? Annotation) != null) - } catch (e: NoClassDefFoundError) { - false - } catch (e: Exception) { - false - } + internal fun generateOutputNameWithStrategies(): String { + for (strategy in testNameExtractionStrategies) { + strategy.extract()?.let { (className, methodName) -> + return generateOutputName(className, methodName) } - ?: throw IllegalArgumentException("Roborazzi can't find method of test. Please specify file name or use Rule") - val testName = - generateOutputName(stackTraceElement.className, stackTraceElement.methodName) - return testName + } + + throw IllegalArgumentException("Roborazzi can't find method of test. Please specify file name or use Rule") } private fun countableOutputName(testName: String): String { diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt index 1458cd03..2bacffb3 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt @@ -68,6 +68,26 @@ fun roborazziDefaultNamingStrategy(): DefaultFileNameGenerator.DefaultNamingStra ) } +fun roborazziTestNameExtractionStrategies(): List { + return buildList { + // Always use the default strategy with stack traces, + // then add the JUnit 5 integration as well (if present on the classpath) + add(StackTraceTestNameExtractionStrategy) + junit5TestNameExtractionStrategy?.let(::add) + } +} + +private val junit5TestNameExtractionStrategy by lazy { + try { + Class.forName("com.github.takahirom.roborazzi.junit5.JUnit5TestNameExtractionStrategy") + .getConstructor() + .newInstance() + as TestNameExtractionStrategy + } catch (ignored: ClassNotFoundException) { + null + } +} + data class RoborazziOptions( /** * This option, taskType, is experimental. So the API may change. diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/StackTraceTestNameExtractionStrategy.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/StackTraceTestNameExtractionStrategy.kt new file mode 100644 index 00000000..3e011b22 --- /dev/null +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/StackTraceTestNameExtractionStrategy.kt @@ -0,0 +1,59 @@ +package com.github.takahirom.roborazzi + +import org.junit.Test +import java.lang.reflect.Method + +/** + * Default strategy for finding a suitable output name for [DefaultFileNameGenerator]. + * This implementation looks up the test class and method from the current stack trace. + */ +internal object StackTraceTestNameExtractionStrategy : TestNameExtractionStrategy { + override fun extract(): Pair? { + // Find test method name + val allStackTraces = Thread.getAllStackTraces() + val filteredTraces = allStackTraces + // The Thread Name is come from here + // https://github.com/robolectric/robolectric/blob/40832ada4a0651ecbb0151ebed2c99e9d1d71032/robolectric/src/main/java/org/robolectric/internal/AndroidSandbox.java#L67 + .filterKeys { + it.name.contains("Main Thread") + || it.name.contains("Test worker") + } + val traceElements = filteredTraces + .flatMap { it.value.toList() } + val stackTraceElement = traceElements + .firstOrNull { + try { + val method = Class.forName(it.className).getMethod(it.methodName) + method.isJUnit4Test() || method.isJUnit5Test() + } catch (e: NoClassDefFoundError) { + false + } catch (e: Exception) { + false + } + } + + return stackTraceElement?.let { + it.className to it.methodName + } + } + + private fun Method.isJUnit4Test(): Boolean { + return getAnnotation(Test::class.java) != null + } + + // This JUnit 5 check works for basic usage of kotlin.test.Test with JUnit 5 + // in basic Compose desktop and multiplatform applications. For more complex + // support including dynamic tests, [JUnit5TestNameExtractionStrategy] is required + @Suppress("UNCHECKED_CAST") + private val jupiterTestAnnotationOrNull = try { + Class.forName("org.junit.jupiter.api.Test") as Class + } catch (e: ClassNotFoundException) { + null + } + + @Suppress("USELESS_CAST") + private fun Method.isJUnit5Test(): Boolean { + return (jupiterTestAnnotationOrNull != null && + (getAnnotation(jupiterTestAnnotationOrNull) as? Annotation) != null) + } +} diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/TestNameExtractionStrategy.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/TestNameExtractionStrategy.kt new file mode 100644 index 00000000..7ca5aa1d --- /dev/null +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/TestNameExtractionStrategy.kt @@ -0,0 +1,5 @@ +package com.github.takahirom.roborazzi + +interface TestNameExtractionStrategy { + fun extract(): Pair? +} diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/roboOutputName.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/roboOutputName.kt index c11bc356..331e2e16 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/roboOutputName.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/roboOutputName.kt @@ -11,5 +11,5 @@ fun roboOutputName(): String { if (description != null) { return DefaultFileNameGenerator.generateOutputNameWithDescription(description) } - return DefaultFileNameGenerator.generateOutputNameWithStackTrace() + return DefaultFileNameGenerator.generateOutputNameWithStrategies() } \ No newline at end of file diff --git a/include-build/roborazzi-gradle-plugin/build.gradle b/include-build/roborazzi-gradle-plugin/build.gradle index cb360dfe..ffc0fbbf 100644 --- a/include-build/roborazzi-gradle-plugin/build.gradle +++ b/include-build/roborazzi-gradle-plugin/build.gradle @@ -22,6 +22,8 @@ dependencies { integrationTestDepImplementation libs.android.tools.build.gradle integrationTestDepImplementation libs.kotlin.gradle.plugin integrationTestDepImplementation libs.compose.gradle.plugin + integrationTestDepImplementation libs.android.junit5.plugin + integrationTestDepImplementation libs.robolectric.junit5.plugin } sourceSets { diff --git a/roborazzi-junit5/.gitignore b/roborazzi-junit5/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/roborazzi-junit5/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/roborazzi-junit5/build.gradle b/roborazzi-junit5/build.gradle new file mode 100644 index 00000000..bd19d4b7 --- /dev/null +++ b/roborazzi-junit5/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' +} +if (System.getenv("INTEGRATION_TEST") != "true") { + pluginManager.apply("com.vanniktech.maven.publish") +} + +test { + useJUnitPlatform() + systemProperty("junit.jupiter.extensions.autodetection.enabled", true) + systemProperty("junit.jupiter.execution.parallel.enabled", true) +} + +dependencies { + // Please see settings.gradle + implementation "io.github.takahirom.roborazzi:roborazzi-core:$VERSION_NAME" + implementation libs.junit.jupiter.api + + testImplementation libs.junit + testImplementation libs.junit.jupiter.api + testImplementation libs.junit.jupiter.params + testRuntimeOnly libs.junit.jupiter.engine + testRuntimeOnly libs.junit.vintage.engine +} \ No newline at end of file diff --git a/roborazzi-junit5/consumer-rules.pro b/roborazzi-junit5/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/roborazzi-junit5/proguard-rules.pro b/roborazzi-junit5/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/roborazzi-junit5/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/CurrentTestInfo.kt b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/CurrentTestInfo.kt new file mode 100644 index 00000000..523ee1cf --- /dev/null +++ b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/CurrentTestInfo.kt @@ -0,0 +1,25 @@ +package com.github.takahirom.roborazzi.junit5 + +import org.junit.jupiter.api.TestInfo + +/** + * A shared, static data container for storing the currently executed test method. + * This is updated by [RoborazziExtension] and read by [JUnit5TestNameExtractionStrategy] + * from different class loaders, bridging the gap between test definition and their execution. + */ +internal object CurrentTestInfo { + private val concurrentRef = ThreadLocal() + private var sameThreadRef: TestInfo? = null + + fun set(info: TestInfo?, isConcurrent: Boolean) { + if (isConcurrent) { + concurrentRef.set(info) + } else { + sameThreadRef = info + } + } + + fun get(): TestInfo? { + return concurrentRef.get() ?: sameThreadRef + } +} diff --git a/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/JUnit5TestNameExtractionStrategy.kt b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/JUnit5TestNameExtractionStrategy.kt new file mode 100644 index 00000000..11d4424f --- /dev/null +++ b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/JUnit5TestNameExtractionStrategy.kt @@ -0,0 +1,35 @@ +package com.github.takahirom.roborazzi.junit5 + +import com.github.takahirom.roborazzi.TestNameExtractionStrategy +import org.junit.jupiter.api.TestInfo + +/** + * Implementation of [TestNameExtractionStrategy] for JUnit 5 tests using Roborazzi. + * Since JUnit 5's dynamic test method names cannot be detected reliably via the default strategy, + * utilize the built-in extension model to track the class and method of the currently executed test. + * + * This class is executed from the Robolectric main thread using its sandboxed class loader, + * which is why it has to jump through several hoops to obtain the static knowledge + * stored inside [CurrentTestInfo]. We need to utilize reflection to access its getter method + * to prevent accidentally creating a second object and missing the actual value. + */ +internal class JUnit5TestNameExtractionStrategy : TestNameExtractionStrategy { + private val getCurrentTestInfo by lazy { createCurrentTestInfoGetterWithReflection() } + + override fun extract(): Pair? { + return getCurrentTestInfo()?.let { info -> + info.testClass.get().name to info.testMethod.get().name + } + } + + private fun createCurrentTestInfoGetterWithReflection(): () -> TestInfo? { + // Ensure usage of the system class loader here, + // which is also used by RoborazziExtension + val cl = ClassLoader.getSystemClassLoader() + val cls = cl.loadClass(CurrentTestInfo::class.java.name) + val instance = cls.getDeclaredField("INSTANCE").get(null) + val method = cls.getDeclaredMethod("get") + + return { method.invoke(instance) as? TestInfo } + } +} diff --git a/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/RoborazziExtension.kt b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/RoborazziExtension.kt new file mode 100644 index 00000000..ad594603 --- /dev/null +++ b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/RoborazziExtension.kt @@ -0,0 +1,31 @@ +package com.github.takahirom.roborazzi.junit5 + +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.parallel.ExecutionMode + +/** + * A JUnit 5 extension to track the currently executed test methods in a static reference. + * This allows the [JUnit5TestNameExtractionStrategy] to extract the correct file name + * for the default image capture when called from a JUnit 5 test method. + */ +class RoborazziExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + val info = TestInfoImpl( + displayName = context.displayName, + tags = context.tags, + testClass = context.testClass, + testMethod = context.testMethod, + ) + + val isConcurrent = requireNotNull(context.executionMode) == ExecutionMode.CONCURRENT + CurrentTestInfo.set(info, isConcurrent) + } + + override fun afterEach(context: ExtensionContext) { + val isConcurrent = requireNotNull(context.executionMode) == ExecutionMode.CONCURRENT + CurrentTestInfo.set(null, isConcurrent) + } +} diff --git a/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/TestInfoImpl.kt b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/TestInfoImpl.kt new file mode 100644 index 00000000..29304100 --- /dev/null +++ b/roborazzi-junit5/src/main/kotlin/com/github/takahirom/roborazzi/junit5/TestInfoImpl.kt @@ -0,0 +1,32 @@ +package com.github.takahirom.roborazzi.junit5 + +import org.junit.jupiter.api.TestInfo +import java.lang.reflect.Method +import java.util.Optional + +internal class TestInfoImpl( + private val displayName: String, + private val tags: MutableSet, + private val testClass: Optional>, + private val testMethod: Optional, +) : TestInfo { + override fun getDisplayName(): String { + return displayName + } + + override fun getTags(): MutableSet { + return tags + } + + override fun getTestClass(): Optional> { + return testClass + } + + override fun getTestMethod(): Optional { + return testMethod + } + + override fun toString(): String { + return testMethod.toString() + } +} diff --git a/roborazzi-junit5/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/roborazzi-junit5/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..9233645e --- /dev/null +++ b/roborazzi-junit5/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +com.github.takahirom.roborazzi.junit5.RoborazziExtension diff --git a/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/CurrentTestInfoTest.kt b/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/CurrentTestInfoTest.kt new file mode 100644 index 00000000..3e76db31 --- /dev/null +++ b/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/CurrentTestInfoTest.kt @@ -0,0 +1,75 @@ +package com.github.takahirom.roborazzi.junit5 + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.RepetitionInfo +import org.junit.jupiter.api.TestInfo +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import java.util.Optional + +class CurrentTestInfoTest { + private val methods = Methods::class.java.declaredMethods + + @Execution(ExecutionMode.CONCURRENT) + @RepeatedTest(100) + fun concurrentTest(repetitionInfo: RepetitionInfo) { + val index = repetitionInfo.currentRepetition + + // Part 1: Apply a non-null info object + doRoundTrip( + TestInfoImpl( + displayName = "Test $index", + tags = mutableSetOf("test$index"), + testClass = Optional.of(Methods::class.java), + testMethod = Optional.of(methods[index % methods.size]), + ) + ) { expected, actual -> + assertEquals(expected?.displayName, actual?.displayName) + assertEquals(expected?.tags, actual?.tags) + assertEquals(expected?.testClass, actual?.testClass) + assertEquals(expected?.testMethod, actual?.testMethod) + } + + // Part 2: Clear this info object again + doRoundTrip(null) { _, actual -> + assertNull(actual) + } + } + + private fun doRoundTrip( + expected: TestInfo?, + assertionBlock: (TestInfo?, TestInfo?) -> Unit, + ) { + CurrentTestInfo.set(expected, isConcurrent = true) + val actual = CurrentTestInfo.get() + assertionBlock(expected, actual) + } + + // Dummy class for this test, used to assign + // random methods from each invocation + // of the concurrent test declared above + @Suppress("unused") + private class Methods { + fun test1() {} + fun test2() {} + fun test3() {} + fun test4() {} + fun test5() {} + fun test6() {} + fun test7() {} + fun test8() {} + fun test9() {} + fun test10() {} + fun test11() {} + fun test12() {} + fun test13() {} + fun test14() {} + fun test15() {} + fun test16() {} + fun test17() {} + fun test18() {} + fun test19() {} + } +} diff --git a/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/CustomTest.kt b/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/CustomTest.kt new file mode 100644 index 00000000..9b99034f --- /dev/null +++ b/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/CustomTest.kt @@ -0,0 +1,56 @@ +package com.github.takahirom.roborazzi.junit5 + +import org.junit.jupiter.api.TestTemplate +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.ParameterContext +import org.junit.jupiter.api.extension.ParameterResolver +import org.junit.jupiter.api.extension.TestTemplateInvocationContext +import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider +import java.util.stream.Stream + +/** + * Example of a custom JUnit 5 test template, ensuring that Roborazzi file name generation + * also works with custom extensions to the Jupiter test model. The idea of [CustomTest] + * is to simply run any annotated test method twice. + */ +@TestTemplate +@ExtendWith(CustomTestTemplateContextProvider::class) +annotation class CustomTest + +private class CustomTestTemplateContextProvider : TestTemplateInvocationContextProvider { + override fun supportsTestTemplate(context: ExtensionContext?): Boolean { + return true + } + + override fun provideTestTemplateInvocationContexts(context: ExtensionContext?): Stream { + return Stream.of(CustomTestTemplateContext(true), CustomTestTemplateContext(false)) + } +} + +private class CustomTestTemplateContext(private val isFirst: Boolean) : + TestTemplateInvocationContext { + override fun getDisplayName(invocationIndex: Int) = buildString { + append(super.getDisplayName(invocationIndex)) + append(if (isFirst) " first" else " second") + append(" invocation") + } + + override fun getAdditionalExtensions() = listOf(CustomTestParameterResolver(isFirst)) +} + +private class CustomTestParameterResolver(private val isFirst: Boolean) : ParameterResolver { + override fun supportsParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Boolean { + return parameterContext.parameter.type == Boolean::class.java + } + + override fun resolveParameter( + parameterContext: ParameterContext?, + extensionContext: ExtensionContext? + ): Any { + return isFirst + } +} diff --git a/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/DefaultFileNameGeneratorTest.kt b/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/DefaultFileNameGeneratorTest.kt new file mode 100644 index 00000000..bd72a011 --- /dev/null +++ b/roborazzi-junit5/src/test/kotlin/com/github/takahirom/roborazzi/junit5/DefaultFileNameGeneratorTest.kt @@ -0,0 +1,74 @@ +package com.github.takahirom.roborazzi.junit5 + +import com.github.takahirom.roborazzi.DefaultFileNameGenerator +import org.junit.jupiter.api.DynamicContainer +import org.junit.jupiter.api.DynamicContainer.dynamicContainer +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.RepetitionInfo +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +abstract class DefaultFileNameGeneratorTest { + protected fun runTest(expectedSuffix: String) { + val filePath = DefaultFileNameGenerator.generateFilePath("png") + val expectedFileName = "${javaClass.name}.$expectedSuffix" + + require(filePath.endsWith(expectedFileName)) { + "Expected generated file name to be '$expectedFileName', but actual file path was: $filePath" + } + } +} + +class DefaultFileNameGeneratorTestWithJUnit4 : DefaultFileNameGeneratorTest() { + @org.junit.Test + fun test() { + runTest("test.png") + } +} + +class DefaultFileNameGeneratorTestWithJUnit5 : DefaultFileNameGeneratorTest() { + @Test + fun test() { + runTest("test.png") + } + + @ParameterizedTest + @ValueSource(strings = ["A", "B"]) + fun parameterizedTest(value: String) { + if (value == "A") { + runTest("parameterizedTest.png") + } else { + runTest("parameterizedTest_2.png") + } + } + + @RepeatedTest(3) + fun repeatedTest(info: RepetitionInfo) { + when (info.currentRepetition) { + 1 -> runTest("repeatedTest.png") + 2 -> runTest("repeatedTest_2.png") + 3 -> runTest("repeatedTest_3.png") + } + } + + @TestFactory + fun testFactory(): DynamicContainer = dynamicContainer( + "testFactory", + listOf( + dynamicTest("first test") { runTest("testFactory.png") }, + dynamicTest("second test") { runTest("testFactory_2.png") }, + ) + ) + + @CustomTest + fun customTest(firstExecution: Boolean) { + if (firstExecution) { + runTest("customTest.png") + } else { + runTest("customTest_2.png") + } + } +} diff --git a/sample-android-junit5/.gitignore b/sample-android-junit5/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/sample-android-junit5/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample-android-junit5/build.gradle.kts b/sample-android-junit5/build.gradle.kts new file mode 100644 index 00000000..8541a1e5 --- /dev/null +++ b/sample-android-junit5/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("com.android.library") + kotlin("android") + id("io.github.takahirom.roborazzi") + id("de.mannodermaus.android-junit5") + id("tech.apter.junit5.jupiter.robolectric-extension-gradle-plugin") +} + +android { + namespace = "com.github.takahirom.roborazzi.sample" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + targetSdk = 34 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + viewBinding = true + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +junitPlatform { + configurationParameter("junit.jupiter.extensions.autodetection.enabled", "true") +} + +dependencies { + testImplementation(project(":roborazzi")) + testImplementation(project(":roborazzi-junit5")) + + implementation(kotlin("stdlib")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.google.android.material) + + testImplementation(libs.robolectric) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.androidx.test.espresso.core) + testRuntimeOnly(libs.junit.jupiter.engine) + + androidTestImplementation(libs.junit.jupiter.api) + androidTestImplementation(libs.androidx.test.runner) +} diff --git a/sample-android-junit5/proguard-rules.pro b/sample-android-junit5/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/sample-android-junit5/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sample-android-junit5/src/androidTest/kotlin/com/github/takahirom/roborazzi/sample/JUnit5InstrumentedTest.kt b/sample-android-junit5/src/androidTest/kotlin/com/github/takahirom/roborazzi/sample/JUnit5InstrumentedTest.kt new file mode 100644 index 00000000..3b4e6764 --- /dev/null +++ b/sample-android-junit5/src/androidTest/kotlin/com/github/takahirom/roborazzi/sample/JUnit5InstrumentedTest.kt @@ -0,0 +1,14 @@ +package com.github.takahirom.roborazzi.sample + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class JUnit5InstrumentedTest { + @Test + fun useAppContext() { + // The basic example instrumentation test, but with JUnit 5 + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.github.takahirom.roborazzi.sample.test", appContext.packageName) + } +} diff --git a/sample-android-junit5/src/main/AndroidManifest.xml b/sample-android-junit5/src/main/AndroidManifest.xml new file mode 100644 index 00000000..38fc16e9 --- /dev/null +++ b/sample-android-junit5/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/sample-android-junit5/src/main/kotlin/com/github/takahirom/roborazzi/sample/MainActivity.kt b/sample-android-junit5/src/main/kotlin/com/github/takahirom/roborazzi/sample/MainActivity.kt new file mode 100644 index 00000000..cad33309 --- /dev/null +++ b/sample-android-junit5/src/main/kotlin/com/github/takahirom/roborazzi/sample/MainActivity.kt @@ -0,0 +1,22 @@ +package com.github.takahirom.roborazzi.sample + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.github.takahirom.roborazzi.sample.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.updateButton.setOnClickListener { + val input = binding.inputEditText.text ?: "" + + binding.descriptionText.text = getString(R.string.text_description_2, input) + } + } +} diff --git a/sample-android-junit5/src/main/res/layout/activity_main.xml b/sample-android-junit5/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..55ea5064 --- /dev/null +++ b/sample-android-junit5/src/main/res/layout/activity_main.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + +