Skip to content

Commit

Permalink
feat(runner): Add a custom robolectric runner which is able to manage…
Browse files Browse the repository at this point in the history
… JUnit Jupiter annotations (#5)

* feat(runner): Add a custom robolectric runner which is able to manage JUnit Jupiter annotations

* chore: Update git ignore

* chore: Update parameterizedTestAnnotation to lazy

* fix: logging to trace
  • Loading branch information
warnyul authored Mar 9, 2024
1 parent beb1b84 commit 1801863
Show file tree
Hide file tree
Showing 15 changed files with 412 additions and 24 deletions.
18 changes: 17 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@ build/
!**/src/test/**/build/

### IntelliJ IDEA ###

.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/deploymentTargetDropDown.xml
.idea/androidTestResultsUserPreferences.xml
.idea/misc.xml
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
Expand Down Expand Up @@ -39,4 +52,7 @@ bin/
.vscode/

### Mac OS ###
.DS_Store
.DS_Store

### Android ###
local.properties
2 changes: 0 additions & 2 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .idea/detekt.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions .idea/migrations.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 0 additions & 10 deletions .idea/misc.xml

This file was deleted.

4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
allprojects {
group = 'tech.apter.junit5.jupiter'
version = '1.0-SNAPSHOT'
}
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
android.useAndroidX=true
kotlin.code.style=official
10 changes: 9 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
[versions]
androidBuildTools = "8.3.0"
androidCompileSdk = "34"
androidJUnit5 = "1.10.0.0"
androidxTestExtJunit = "1.1.5"
junit4 = "4.13.2"
junit5 = "5.10.2"
jvmToolchain = "17"
Expand All @@ -8,17 +12,21 @@ robolectric = "4.11.1"
sources = "sources"

[libraries]
androidxTestExtJunit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" }
guava = { module = "com.google.guava:guava", version = { require = "[32.0.1-jre,]" } }
junit4 = { module = "junit:junit", version.ref = "junit4" }
junit5Bom = { module = "org.junit:junit-bom", version.ref = "junit5" }
junit5JupiterApi = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "sources" }
junit5JupiterEngine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "sources" }
junit5JupiterParams = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "sources" }
junit5PlatformLauncher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "sources" }
kotlinTestJUnit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }

[plugins]
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
androidJUnit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJUnit5" }
androidLibrary = { id = "com.android.library", version.ref = "androidBuildTools" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

[bundles]
junit5 = [
Expand Down
27 changes: 20 additions & 7 deletions robolectric-extension/build.gradle
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.androidJUnit5)
}

group = 'tech.apter.junit5.jupiter'
version = '1.0-SNAPSHOT'
android {
defaultConfig {
namespace = "$group.$name"
compileOptions {
compileSdk libs.versions.androidCompileSdk.get().toInteger()
}
}
testOptions {
unitTests.all {
useJUnitPlatform()
}
}
}

dependencies {
implementation libs.robolectric
implementation libs.junit4
implementation platform(libs.junit5Bom)
implementation libs.bundles.junit5
testImplementation libs.junit4
testImplementation libs.androidxTestExtJunit
testImplementation libs.kotlinTestJUnit5
testImplementation libs.junit5JupiterParams
testRuntimeOnly libs.junit5JupiterEngine
implementation(libs.guava) {
because "CVE-2023-2976 7.1 " +
"Transitive Files or Directories Accessible to External Parties vulnerability with High severity found"
}
}

test {
useJUnitPlatform()
}

kotlin {
jvmToolchain(libs.versions.jvmToolchain.get().toInteger())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package tech.apter.junit.jupiter.robolectric.internal

import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.runners.BlockJUnit4ClassRunner
import org.junit.runners.model.FrameworkMethod
import java.util.Collections

private val parameterizedTestAnnotation: Class<out Annotation>? by lazy {
try {
@Suppress("UNCHECKED_CAST")
Class.forName("org.junit.jupiter.params.ParameterizedTest") as? Class<out Annotation>
} catch (@Suppress("SwallowedException") e: ClassNotFoundException) {
null
}
}

internal fun BlockJUnit4ClassRunner.computeJUnit5TestMethods(): MutableList<FrameworkMethod> {
val testMethods = testClass.getAnnotatedMethods(Test::class.java)
val parameterizedTestMethods = if (parameterizedTestAnnotation == null) {
emptyList()
} else {
testClass.getAnnotatedMethods(parameterizedTestAnnotation)
}

val methods = mutableListOf<FrameworkMethod>()
methods.addAll(testMethods)
methods.addAll(parameterizedTestMethods)
return Collections.unmodifiableList(methods)
}

@Suppress("UnusedReceiverParameter")
internal fun BlockJUnit4ClassRunner.isJUnit5Ignored(child: FrameworkMethod) =
child.getAnnotation(Disabled::class.java) != null

/**
* Adds to errors if any method in this class is annotated with annotation, but:
*
* * is not public, or
* * takes parameters, or
* * returns something other than void, or
* * is static (given isStatic is false), or
* * is not static (given isStatic is true).
*/
internal fun BlockJUnit4ClassRunner.validatePublicVoidNoArgJUnit5Methods(
annotation: Class<out Annotation>,
isStatic: Boolean, errors: MutableList<Throwable>,
) {
if (annotation == org.junit.Test::class.java) {
validatePublicVoidNoArgMethods(Test::class.java, isStatic, errors)
parameterizedTestAnnotation?.let { validatePublicVoidArgMethods(it, isStatic, errors) }
} else {
val jUnit5Annotation = when (annotation) {
Before::class.java -> BeforeEach::class.java
After::class.java -> After::class.java
BeforeClass::class.java -> BeforeAll::class.java
AfterClass::class.java -> AfterAll::class.java
else -> annotation
}
validatePublicVoidNoArgMethods(jUnit5Annotation, isStatic, errors)
}
}

/**
* Adds to `errors` if any method in this class is annotated with
* `annotation`, but:
*
* * is not public, or
* * takes parameters, or
* * returns something other than void, or
* * is static (given `isStatic is false`), or
* * is not static (given `isStatic is true`).
*/
internal fun BlockJUnit4ClassRunner.validatePublicVoidNoArgMethods(
annotation: Class<out Annotation>,
isStatic: Boolean,
errors: List<Throwable>,
) {
val methods: List<FrameworkMethod> = testClass.getAnnotatedMethods(annotation)
for (eachTestMethod in methods) {
eachTestMethod.validatePublicVoidNoArg(isStatic, errors)
}
}

/**
* Adds to `errors` if any method in this class is annotated with
* `annotation`, but:
*
* * is not public, or
* * returns something other than void, or
* * is static (given `isStatic is false`), or
* * is not static (given `isStatic is true`).
*/
internal fun BlockJUnit4ClassRunner.validatePublicVoidArgMethods(
@Suppress("SameParameterValue") annotation: Class<out Annotation>,
isStatic: Boolean,
errors: List<Throwable>,
) {
val methods = testClass.getAnnotatedMethods(annotation)
for (eachTestMethod in methods) {
eachTestMethod.validatePublicVoid(isStatic, errors)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package tech.apter.junit.jupiter.robolectric.internal

import org.junit.runners.model.FrameworkMethod
import org.robolectric.RobolectricTestRunner
import org.robolectric.internal.SandboxManager.SandboxBuilder
import org.robolectric.internal.SandboxTestRunner
import org.robolectric.internal.bytecode.InstrumentationConfiguration
import org.robolectric.internal.bytecode.Sandbox
import java.lang.reflect.Method

internal class JUnit5RobolectricTestRunner(clazz: Class<*>) :
RobolectricTestRunner(clazz, injector) {
private val logger get() = createLogger()
fun frameworkMethod(method: Method): FrameworkMethod = children.first { it.name == method.name }

fun bootstrapSdkEnvironment(): Sandbox = sdkEnvironment(children.first())

fun sdkEnvironment(frameworkMethod: FrameworkMethod): Sandbox {
return getSandbox(frameworkMethod).also {
configureSandbox(it, frameworkMethod)
}
}

fun runBeforeTest(
sdkEnvironment: Sandbox,
frameworkMethod: FrameworkMethod,
bootstrappedMethod: Method,
) {
logger.trace {
"runBeforeTest ${bootstrappedMethod.declaringClass.simpleName}::${bootstrappedMethod.name}"
}
super.beforeTest(sdkEnvironment, frameworkMethod, bootstrappedMethod)
}

fun runAfterTest(frameworkMethod: FrameworkMethod, bootstrappedMethod: Method) {
logger.trace {
"runAfterTest${bootstrappedMethod.declaringClass.simpleName}::${bootstrappedMethod.name}"
}
try {
super.afterTest(frameworkMethod, bootstrappedMethod)
} finally {
super.finallyAfterTest(frameworkMethod)
}
}

override fun createClassLoaderConfig(method: FrameworkMethod): InstrumentationConfiguration {
return InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method))
.doNotAcquirePackage("tech.apter.junit.jupiter.robolectric").build()
}

override fun computeTestMethods() = computeJUnit5TestMethods()

override fun isIgnored(child: FrameworkMethod) = isJUnit5Ignored(child)

override fun validatePublicVoidNoArgMethods(
annotation: Class<out Annotation>,
isStatic: Boolean,
errors: MutableList<Throwable>,
) = validatePublicVoidNoArgJUnit5Methods(annotation, isStatic, errors)

override fun getHelperTestRunner(bootstrappedTestClass: Class<*>): SandboxTestRunner.HelperTestRunner =
HelperTestRunner(bootstrappedTestClass)

private class HelperTestRunner(bootstrappedTestClass: Class<*>) :
RobolectricTestRunner.HelperTestRunner(bootstrappedTestClass) {
override fun computeTestMethods(): MutableList<FrameworkMethod> = computeJUnit5TestMethods()

override fun isIgnored(child: FrameworkMethod) = isJUnit5Ignored(child)

override fun validatePublicVoidNoArgMethods(
annotation: Class<out Annotation>,
isStatic: Boolean,
errors: MutableList<Throwable>,
) = validatePublicVoidNoArgJUnit5Methods(annotation, isStatic, errors)
}

private companion object {
private val injector = defaultInjector()
.bind(SandboxBuilder::class.java, JUnit5RobolectricSandboxBuilder::class.java)
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ class JUnit5RobolectricSandboxBuilderTest {
ApkLoader(),
AndroidSandbox.TestEnvironmentSpec(),
ShadowProviders(emptyList()),
).apply {
action()
}
).apply(action)

companion object {
private fun createInstrumentationConfiguration() =
Expand Down
Loading

0 comments on commit 1801863

Please sign in to comment.