Skip to content

Commit

Permalink
feat(runner): Add Robolectric test runner helper (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
warnyul authored Mar 13, 2024
1 parent 18b9da1 commit 2192659
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 12 deletions.
9 changes: 9 additions & 0 deletions robolectric-extension/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ configurations.configureEach { configuration ->
configuration.exclude(group: 'androidx.annotation', module: 'annotation-experimental')
}

kotlin {
jvmToolchain(libs.versions.jvmToolchain.get().toInteger())
}

test {
useJUnitPlatform()
ignoreFailures = true
}

dependencies {
implementation libs.robolectric
implementation libs.junit4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.robolectric.internal.SandboxTestRunner
import org.robolectric.internal.bytecode.InstrumentationConfiguration
import org.robolectric.internal.bytecode.Sandbox
import org.robolectric.util.inject.Injector
import tech.apter.junit.jupiter.robolectric.RobolectricExtension

internal class JUnit5RobolectricTestRunner(clazz: Class<*>, injector: Injector = defaultInjectorBuilder().build()) :
RobolectricTestRunner(clazz, injector) {
Expand All @@ -27,26 +28,25 @@ internal class JUnit5RobolectricTestRunner(clazz: Class<*>, injector: Injector =
frameworkMethod: FrameworkMethod,
bootstrappedMethod: Method,
) {
logger.trace {
"runBeforeTest ${bootstrappedMethod.declaringClass.simpleName}::${bootstrappedMethod.name}"
}
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)
}
logger.trace { "runAfterTest ${frameworkMethod.declaringClass.simpleName}::${frameworkMethod.name}" }
super.afterTest(frameworkMethod, bootstrappedMethod)
}

fun runFinallyAfterTest(frameworkMethod: FrameworkMethod) {
logger.trace { "runFinallyAfterTest ${frameworkMethod.declaringClass.simpleName}::${frameworkMethod.name}" }
super.finallyAfterTest(frameworkMethod)
}

override fun createClassLoaderConfig(method: FrameworkMethod): InstrumentationConfiguration {
logger.trace { "createClassLoaderConfig for ${method.name}" }
return InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method))
.doNotAcquireClass(JUnit5RobolectricSandboxBuilder::class.java)
.doNotAcquirePackage("tech.apter.junit.jupiter.robolectric.internal")
.doNotAcquireClass(RobolectricExtension::class.java)
.build()
}

Expand Down Expand Up @@ -76,6 +76,7 @@ internal class JUnit5RobolectricTestRunner(clazz: Class<*>, injector: Injector =
) = validatePublicVoidNoArgJUnit5Methods(annotation, isStatic, errors)
}


private companion object {
private fun defaultInjectorBuilder() = defaultInjector()
.bind(SandboxBuilder::class.java, JUnit5RobolectricSandboxBuilder::class.java)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package tech.apter.junit.jupiter.robolectric.internal

import com.google.common.annotations.VisibleForTesting
import java.lang.reflect.Method
import org.junit.runners.model.FrameworkMethod
import org.robolectric.internal.bytecode.Sandbox

internal class JUnit5RobolectricTestRunnerHelper {
private inline val logger get() = createLogger()
private var _robolectricTestRunner: JUnit5RobolectricTestRunner? = null
private var _sdkEnvironment: Sandbox? = null
private var _frameworkMethod: FrameworkMethod? = null

@VisibleForTesting
val robolectricTestRunner: JUnit5RobolectricTestRunner get() = requireNotNull(_robolectricTestRunner)
val sdkEnvironment: Sandbox get() = requireNotNull(_sdkEnvironment)

@VisibleForTesting
val frameworkMethod: FrameworkMethod get() = requireNotNull(_frameworkMethod)

fun loadRobolectricClassLoader() {
logger.trace { "loadRobolectricClassLoader" }
Thread.currentThread().replaceClassLoader(sdkEnvironment.robolectricClassLoader).also {
if (interceptedClassLoader == null) {
interceptedClassLoader = it
}
}
}

fun resetClassLoaderToOriginal() {
logger.trace { "resetClassLoaderToOriginal" }
if (interceptedClassLoader != null) {
Thread.currentThread().contextClassLoader = interceptedClassLoader
}
}

fun createTestEnvironmentForClass(testClass: Class<*>) {
_robolectricTestRunner = JUnit5RobolectricTestRunner(testClass)
_sdkEnvironment = robolectricTestRunner.bootstrapSdkEnvironment()
}

fun createTestEnvironmentForMethod(testMethod: Method) {
val frameworkMethod = robolectricTestRunner.frameworkMethod(testMethod)
_sdkEnvironment = robolectricTestRunner.sdkEnvironment(frameworkMethod)
_frameworkMethod = frameworkMethod
}

fun beforeEach(testMethod: Method) {
robolectricTestRunner.runBeforeTest(sdkEnvironment, frameworkMethod, testMethod)
}

fun afterEach(testMethod: Method) {
robolectricTestRunner.runAfterTest(frameworkMethod, testMethod)
}

fun afterEachFinally() {
robolectricTestRunner.runFinallyAfterTest(frameworkMethod)
}

fun clearCachedRobolectricTestRunnerEnvironment() {
_robolectricTestRunner = null
_sdkEnvironment = null
_frameworkMethod = null
}

internal companion object {
internal var interceptedClassLoader: ClassLoader? = null
private set
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package tech.apter.junit.jupiter.robolectric.internal

import java.util.concurrent.Callable

internal fun <T> JUnit5RobolectricTestRunnerHelper.runOnMainThread(action: JUnit5RobolectricTestRunnerHelper.() -> T): T =
sdkEnvironment.runOnMainThread(
Callable {
action()
}
)

internal fun <T> JUnit5RobolectricTestRunnerHelper.runOnMainThreadWithRobolectric(action: JUnit5RobolectricTestRunnerHelper.() -> T): T =
runOnMainThread {
runWithRobolectric(action)
}

internal fun <T> JUnit5RobolectricTestRunnerHelper.runWithRobolectric(action: JUnit5RobolectricTestRunnerHelper.() -> T): T {
loadRobolectricClassLoader()
return action().also {
resetClassLoaderToOriginal()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package tech.apter.junit.jupiter.robolectric.internal

internal fun Thread.replaceClassLoader(classLoader: ClassLoader?): ClassLoader? {
val originalClassLoader = contextClassLoader
contextClassLoader = classLoader
return originalClassLoader
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package tech.apter.junit.jupiter.robolectric

import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import org.robolectric.internal.AndroidSandbox.SdkSandboxClassLoader
import org.robolectric.internal.bytecode.Sandbox
import tech.apter.junit.jupiter.robolectric.internal.JUnit5RobolectricTestRunnerHelper
import tech.apter.junit.jupiter.robolectric.internal.fakes.SingleTestMethodJunitJupiterTest
import tech.apter.junit.jupiter.robolectric.internal.fakes.TwoTestMethodsJunitJupiterTest
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame

class JUnit5RobolectricRunnerHelperTest {

private var originalClassLoader: ClassLoader? = null

@BeforeTest
fun setUp() {
originalClassLoader = JUnit5RobolectricTestRunnerHelper.interceptedClassLoader
}

@AfterTest
fun tearDown() {
Thread.currentThread().contextClassLoader = originalClassLoader
originalClassLoader = null
}

@Test
fun `When call loadRobolectricClassLoader then contextClassLoader should be an instance of SdkSandboxClassLoader`() {
subjectUnderTest {
// Given
createTestEnvironmentForClass(SingleTestMethodJunitJupiterTest::class.java)

// When
loadRobolectricClassLoader()

// Then
assertEquals<Class<*>>(
SdkSandboxClassLoader::class.java,
Thread.currentThread().contextClassLoader.javaClass
)
}
}

@Test
fun `Given the robolectricClassLoader loaded when call reset resetClassLoaderToOriginal then contextClassLoader should not be an instance of SdkSandboxClassLoader`() {
subjectUnderTest {
// Given
createTestEnvironmentForClass(SingleTestMethodJunitJupiterTest::class.java)
loadRobolectricClassLoader()

// When
resetClassLoaderToOriginal()

// Then
val currentClassLoader = Thread.currentThread().contextClassLoader
assertNotEquals<Class<*>>(SdkSandboxClassLoader::class.java, currentClassLoader.javaClass)
assertSame(originalClassLoader, currentClassLoader)
}
}

@Test
fun `Given a configured test environment when call reset resetClassLoaderToOriginal then contextClassLoader should be the same as original`() {
subjectUnderTest {
// Given
createTestEnvironmentForClass(SingleTestMethodJunitJupiterTest::class.java)

// When
resetClassLoaderToOriginal()

// Then
assertSame(originalClassLoader, Thread.currentThread().contextClassLoader)
}
}

@Test
fun `When call reset resetClassLoaderToOriginal then contextClassLoader should be the same as original`() {
subjectUnderTest {
// When
resetClassLoaderToOriginal()

// Then
assertSame(originalClassLoader, Thread.currentThread().contextClassLoader)
}
}

@Test
fun `Initially the test runner helper should be empty`() {
subjectUnderTest {
// Then
assertThrows<IllegalArgumentException> { robolectricTestRunner }
assertThrows<IllegalArgumentException> { sdkEnvironment }
assertThrows<IllegalArgumentException> { frameworkMethod }
}
}

@Test
fun `Given a testRunner when call configure with runner the sdk environment should be set`() {
// Given
val cache = subjectUnderTest {
// When
createTestEnvironmentForClass(TwoTestMethodsJunitJupiterTest::class.java)
}

// Then
assertDoesNotThrow { cache.robolectricTestRunner }
assertEquals(TwoTestMethodsJunitJupiterTest::class.java, cache.robolectricTestRunner.testClass.javaClass)
assertDoesNotThrow { cache.sdkEnvironment }
assertThrows<IllegalArgumentException> { cache.frameworkMethod }
}

@Test
fun `Given the sdk environment configured when call configure with framework method then sdk environment should be reconfigured`() {
// Given
val testClass = TwoTestMethodsJunitJupiterTest::class.java
val testMethod2 = testClass.declaredMethods.first {
it.name == TwoTestMethodsJunitJupiterTest::testMethod2.name
}
lateinit var firstSdkEnvironment: Sandbox

val runnerHelper = subjectUnderTest {
// And
createTestEnvironmentForClass(testClass)
firstSdkEnvironment = sdkEnvironment

// When
createTestEnvironmentForMethod(testMethod2)
}

// Then
assertDoesNotThrow { runnerHelper.robolectricTestRunner }
assertEquals(testClass, runnerHelper.robolectricTestRunner.testClass.javaClass)
assertDoesNotThrow { runnerHelper.sdkEnvironment }
assertNotSame(firstSdkEnvironment, runnerHelper.sdkEnvironment)
assertDoesNotThrow { (runnerHelper.frameworkMethod) }
assertEquals(TwoTestMethodsJunitJupiterTest::testMethod2.name, runnerHelper.frameworkMethod.name)
}

@Test
fun `Given the runnerHelper configured when call clear then the runnerHelper should be empty`() {
// Given
val testClass = TwoTestMethodsJunitJupiterTest::class.java
val testMethod2 = testClass.declaredMethods.first {
it.name == TwoTestMethodsJunitJupiterTest::testMethod2.name
}
val runnerHelper = subjectUnderTest {

// And
createTestEnvironmentForClass(testClass)

// And
createTestEnvironmentForMethod(testMethod2)

// When
clearCachedRobolectricTestRunnerEnvironment()
}

// Then
assertThrows<IllegalArgumentException> { runnerHelper.robolectricTestRunner }
assertThrows<IllegalArgumentException> { runnerHelper.sdkEnvironment }
assertThrows<IllegalArgumentException> { runnerHelper.frameworkMethod }
}

private fun subjectUnderTest(action: JUnit5RobolectricTestRunnerHelper.() -> Unit): JUnit5RobolectricTestRunnerHelper =
JUnit5RobolectricTestRunnerHelper().apply(action)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package tech.apter.junit.jupiter.robolectric.internal.fakes

import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.robolectric.annotation.Config

@Disabled
class TwoTestMethodsJunitJupiterTest {
@Test
fun testMethod1() = Unit

@Test
@Config(minSdk = 33)
fun testMethod2() = Unit
}

0 comments on commit 2192659

Please sign in to comment.