Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Extend support for JUnit 5 via the new roborazzi-junit5 artifact #355

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/roborazzi-docs.tree
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<toc-element topic="build_setup.md"/>
<toc-element topic="how_to_use.md"/>
<toc-element topic="compose_multiplatform.md"/>
<toc-element topic="junit5.md"/>
<toc-element topic="gradle_properties_options.md"/>
<toc-element topic="faq.md"/>
</instance-profile>
87 changes: 87 additions & 0 deletions docs/topics/junit5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# JUnit 5 support
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is easy-to-understand documentation! Thanks!


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 {
// ...
}
```
12 changes: 12 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand All @@ -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" }
Expand All @@ -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" }
Expand All @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -22,6 +20,9 @@ object DefaultFileNameGenerator {
private val defaultNamingStrategy by lazy {
roborazziDefaultNamingStrategy()
}
private val testNameExtractionStrategies by lazy {
roborazziTestNameExtractionStrategies()
}

@InternalRoborazziApi
fun generateFilePath(extension: String): String {
Expand All @@ -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<Annotation>
} 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ fun roborazziDefaultNamingStrategy(): DefaultFileNameGenerator.DefaultNamingStra
)
}

fun roborazziTestNameExtractionStrategies(): List<TestNameExtractionStrategy> {
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>? {
// 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<Annotation>
} catch (e: ClassNotFoundException) {
null
}

@Suppress("USELESS_CAST")
private fun Method.isJUnit5Test(): Boolean {
return (jupiterTestAnnotationOrNull != null &&
(getAnnotation(jupiterTestAnnotationOrNull) as? Annotation) != null)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.github.takahirom.roborazzi

interface TestNameExtractionStrategy {
fun extract(): Pair<String, String>?
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ fun roboOutputName(): String {
if (description != null) {
return DefaultFileNameGenerator.generateOutputNameWithDescription(description)
}
return DefaultFileNameGenerator.generateOutputNameWithStackTrace()
return DefaultFileNameGenerator.generateOutputNameWithStrategies()
}
2 changes: 2 additions & 0 deletions include-build/roborazzi-gradle-plugin/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions roborazzi-junit5/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
24 changes: 24 additions & 0 deletions roborazzi-junit5/build.gradle
Original file line number Diff line number Diff line change
@@ -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
}
Empty file.
Loading
Loading