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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample-android-junit5/src/main/res/values/attrs.xml b/sample-android-junit5/src/main/res/values/attrs.xml
new file mode 100644
index 00000000..0897eb6e
--- /dev/null
+++ b/sample-android-junit5/src/main/res/values/attrs.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/sample-android-junit5/src/main/res/values/strings.xml b/sample-android-junit5/src/main/res/values/strings.xml
new file mode 100644
index 00000000..c1f9df1e
--- /dev/null
+++ b/sample-android-junit5/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+
+ Caption
+ Original description
+ Updated description: %s
+ Update
+ Enter something
+
diff --git a/sample-android-junit5/src/main/res/values/styles.xml b/sample-android-junit5/src/main/res/values/styles.xml
new file mode 100644
index 00000000..07065913
--- /dev/null
+++ b/sample-android-junit5/src/main/res/values/styles.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/sample-android-junit5/src/test/kotlin/com/github/takahirom/roborazzi/sample/JUnit5ManualTest.kt b/sample-android-junit5/src/test/kotlin/com/github/takahirom/roborazzi/sample/JUnit5ManualTest.kt
new file mode 100644
index 00000000..4800f4fb
--- /dev/null
+++ b/sample-android-junit5/src/test/kotlin/com/github/takahirom/roborazzi/sample/JUnit5ManualTest.kt
@@ -0,0 +1,76 @@
+package com.github.takahirom.roborazzi.sample
+
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.typeText
+import androidx.test.espresso.matcher.ViewMatchers.isRoot
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
+import com.github.takahirom.roborazzi.captureRoboImage
+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.api.extension.ExtendWith
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import tech.apter.junit.jupiter.robolectric.RobolectricExtension
+
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(
+ sdk = [30],
+ qualifiers = RobolectricDeviceQualifiers.NexusOne,
+)
+@ExtendWith(RobolectricExtension::class)
+class JUnit5ManualTest {
+ @Test
+ @Config(qualifiers = "+land")
+ fun captureRoboImageSample() {
+ ActivityScenario.launch(MainActivity::class.java)
+
+ onView(isRoot()).captureRoboImage()
+ onView(withId(R.id.inputEditText)).perform(typeText("hello"))
+ onView(withId(R.id.updateButton)).perform(click())
+ onView(isRoot()).captureRoboImage()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun parameterizedTest(value: Boolean) {
+ ActivityScenario.launch(MainActivity::class.java)
+
+ onView(withId(R.id.inputEditText)).perform(typeText("parameter=$value"))
+ onView(withId(R.id.updateButton)).perform(click())
+ onView(isRoot()).captureRoboImage()
+ }
+
+ @RepeatedTest(2)
+ fun repeatedTest(repetitionInfo: RepetitionInfo) {
+ ActivityScenario.launch(MainActivity::class.java)
+
+ onView(withId(R.id.inputEditText)).perform(typeText("repeated=${repetitionInfo.currentRepetition}"))
+ onView(withId(R.id.updateButton)).perform(click())
+ onView(isRoot()).captureRoboImage()
+ }
+
+ @TestFactory
+ fun testFactory() = listOf(
+ dynamicTest("First") {
+ ActivityScenario.launch(MainActivity::class.java)
+
+ onView(withId(R.id.inputEditText)).perform(typeText("typed but not pressed"))
+ onView(isRoot()).captureRoboImage()
+ },
+ dynamicTest("Second") {
+ ActivityScenario.launch(MainActivity::class.java)
+
+ onView(withId(R.id.inputEditText)).perform(typeText("typed and pressed"))
+ onView(withId(R.id.updateButton)).perform(click())
+ onView(isRoot()).captureRoboImage()
+ },
+ )
+}
diff --git a/sample-compose-desktop-jvm/build.gradle.kts b/sample-compose-desktop-jvm/build.gradle.kts
index 5203802b..c0e9b984 100644
--- a/sample-compose-desktop-jvm/build.gradle.kts
+++ b/sample-compose-desktop-jvm/build.gradle.kts
@@ -11,6 +11,7 @@ group = "com.github.takahirom.roborazzi.compose.desktop.jvm.sample"
version = "1.0-SNAPSHOT"
tasks.test {
useJUnitPlatform()
+ systemProperty("junit.jupiter.extensions.autodetection.enabled", true)
}
dependencies {
@@ -20,8 +21,12 @@ dependencies {
// With compose.desktop.common you will also lose @Preview functionality
implementation(compose.desktop.currentOs)
testImplementation(project(":roborazzi-compose-desktop"))
+ testImplementation(project(":roborazzi-junit5"))
testImplementation(kotlin("test"))
+ testImplementation(libs.junit.jupiter.api)
+ testImplementation(libs.junit.jupiter.params)
+ testRuntimeOnly(libs.junit.jupiter.engine)
}
compose.desktop {
diff --git a/sample-compose-desktop-jvm/src/main/kotlin/Main.kt b/sample-compose-desktop-jvm/src/main/kotlin/Main.kt
index cf5d1252..c889e4a0 100644
--- a/sample-compose-desktop-jvm/src/main/kotlin/Main.kt
+++ b/sample-compose-desktop-jvm/src/main/kotlin/Main.kt
@@ -11,14 +11,15 @@ import androidx.compose.ui.window.application
@Composable
@Preview
-fun App() {
+fun App(value: String = "test") {
var text by remember { mutableStateOf("Hello, World!") }
+ val updatedValue by rememberUpdatedState(value)
MaterialTheme {
Button(
modifier = Modifier.testTag("button"),
onClick = {
- text = "Hello, Desktop! test"
+ text = "Hello, Desktop! $updatedValue"
}) {
Text(
style = MaterialTheme.typography.h2,
diff --git a/sample-compose-desktop-jvm/src/test/kotlin/MainJvmTest.kt b/sample-compose-desktop-jvm/src/test/kotlin/MainJvmTest.kt
index cacebc0f..5267d132 100644
--- a/sample-compose-desktop-jvm/src/test/kotlin/MainJvmTest.kt
+++ b/sample-compose-desktop-jvm/src/test/kotlin/MainJvmTest.kt
@@ -2,16 +2,48 @@ import androidx.compose.ui.test.*
import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG
import com.github.takahirom.roborazzi.RoborazziOptions
import io.github.takahirom.roborazzi.captureRoboImage
+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.TestFactory
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
import kotlin.test.Test
class MainJvmTest {
- @OptIn(ExperimentalTestApi::class)
@Test
fun test() {
+ runTest()
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = ["first", "second"])
+ fun parameterizedTest(value: String) {
+ runTest("parameterized $value")
+ }
+
+ @RepeatedTest(2)
+ fun repeatedTest(info: RepetitionInfo) {
+ runTest("repeated ${info.currentRepetition}")
+ }
+
+ @TestFactory
+ fun testFactory(): DynamicContainer = dynamicContainer(
+ "container",
+ listOf(
+ dynamicTest("one") { runTest("dynamic one") },
+ dynamicTest("two") { runTest("dynamic two") },
+ )
+ )
+
+ @OptIn(ExperimentalTestApi::class)
+ private fun runTest(value: String = "test") {
ROBORAZZI_DEBUG = true
runDesktopComposeUiTest {
setContent {
- App()
+ App(value)
}
val roborazziOptions = RoborazziOptions(
compareOptions = RoborazziOptions.CompareOptions(changeThreshold = 0F)
diff --git a/settings.gradle b/settings.gradle
index c4108a23..1603462e 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -15,12 +15,14 @@ dependencyResolutionManagement {
rootProject.name = "roborazzi"
include ':roborazzi'
include ':roborazzi-junit-rule'
+include ':roborazzi-junit5'
include ':roborazzi-compose-desktop'
include ':roborazzi-compose-ios'
include ':roborazzi-compose'
include ':roborazzi-painter'
include ':sample-android'
+include ':sample-android-junit5'
include ':sample-android-without-compose'
include ':sample-compose-desktop-multiplatform'
include ':sample-compose-desktop-jvm'