Skip to content

Commit

Permalink
Merge pull request #20 from isaac-udy/allow-testing-against-non-navig…
Browse files Browse the repository at this point in the history
…ation-applications

Allow testing against non-navigation applications
  • Loading branch information
isaac-udy authored Mar 21, 2021
2 parents e9f9287 + dde2458 commit 8d97efb
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 26 deletions.
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@ on:
- main
jobs:
run-ui-tests:
name: Run UI Tests
name: Run Tests
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v2

- name: Run
- name: Run Enro UI Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew :enro:connectedCheck
script: ./gradlew :enro:connectedCheck

- name: Run Modularised Example Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew :modularised-example:app:testDebugUnitTest
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ jobs:
- name: Checkout
uses: actions/checkout@v2

- name: Run UI Tests
- name: Run Enro UI Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew :enro:connectedCheck

- name: Run Modularised Example Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew :modularised-example:app:testDebugUnitTest

- name: Install gpg secret key
run: cat <(echo -e "${{ secrets.PUBLISH_SIGNING_KEY_LITERAL }}") | gpg --batch --import

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
package dev.enro.core.controller

import android.app.Application
import dev.enro.core.controller.NavigationController

interface NavigationApplication {
val navigationController: NavigationController
}

val Application.navigationController get() = (this as NavigationApplication).navigationController
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ class NavigationController internal constructor(
if (navigationApplication !is Application)
throw IllegalArgumentException("A NavigationApplication must extend android.app.Application")

navigationControllerBindings[navigationApplication] = this
contextController.install(navigationApplication)
}

private fun installForTest(application: Application) {
navigationControllerBindings[application] = this
contextController.install(application)
}

private fun uninstall(application: Application) {
navigationControllerBindings.remove(application)
contextController.uninstall(application)
}

companion object {
internal val navigationControllerBindings = mutableMapOf<Application, NavigationController>()

private fun getBoundApplicationForTest(application: Application) = navigationControllerBindings[application]
}
}

val Application.navigationController: NavigationController get() {
if(this is NavigationApplication) return navigationController
val bound = NavigationController.navigationControllerBindings[this]
if(bound != null) return bound
throw IllegalStateException("Application is not a NavigationApplication, and has no attached NavigationController ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@ package dev.enro.core.controller.lifecycle

import android.app.Activity
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import dev.enro.core.*
import dev.enro.core.ActivityContext
import dev.enro.core.FragmentContext
import dev.enro.core.navigationContext

internal class NavigationContextLifecycleCallbacks (
private val lifecycleController: NavigationLifecycleController
Expand All @@ -25,6 +21,10 @@ internal class NavigationContextLifecycleCallbacks (
application.registerActivityLifecycleCallbacks(activityCallbacks)
}

internal fun uninstall(application: Application) {
application.registerActivityLifecycleCallbacks(activityCallbacks)
}

inner class ActivityCallbacks : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelStoreOwner
import dev.enro.core.*
import dev.enro.core.controller.NavigationApplication
import dev.enro.core.controller.container.ExecutorContainer
import dev.enro.core.controller.container.PluginContainer
import dev.enro.core.internal.NoNavigationKey
Expand All @@ -24,12 +23,13 @@ internal class NavigationLifecycleController(
private val callbacks = NavigationContextLifecycleCallbacks(this)

fun install(application: Application) {
application as? NavigationApplication
?: throw IllegalStateException("Application MUST be a NavigationApplication")

callbacks.install(application)
}

internal fun uninstall(application: Application) {
callbacks.uninstall(application)
}

fun onContextCreated(context: NavigationContext<*>, savedInstanceState: Bundle?) {
if (context is ActivityContext) {
context.activity.theme.applyStyle(android.R.style.Animation_Activity, false)
Expand Down
117 changes: 113 additions & 4 deletions enro-test/src/main/java/dev/enro/test/EnroTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,124 @@ package dev.enro.test

import android.app.Application
import androidx.test.core.app.ApplicationProvider
import dalvik.system.BaseDexClassLoader
import dalvik.system.DexFile
import dev.enro.core.controller.NavigationApplication
import dev.enro.core.controller.NavigationComponentBuilder
import dev.enro.core.controller.NavigationComponentBuilderCommand
import dev.enro.core.controller.NavigationController
import dev.enro.core.plugins.EnroHilt
import dev.enro.core.plugins.EnroLogger
import java.io.File
import java.lang.reflect.Field

object EnroTest {
fun getCurrentNavigationController(): NavigationController {
private val generatedBindings: List<NavigationComponentBuilderCommand> by lazy {
val isDexClassLoader = Thread.currentThread().contextClassLoader::class.java is BaseDexClassLoader
val classes = if(isDexClassLoader) getGeneratedClassesFromDexFile() else getGeneratedClasses()

classes
.filter {
NavigationComponentBuilderCommand::class.java.isAssignableFrom(it)
}
.map {
it.newInstance() as NavigationComponentBuilderCommand
}
.toList()
}

internal fun installNavigationController() {
val application = ApplicationProvider.getApplicationContext<Application>()
val navigationApplication = application as? NavigationApplication
?: throw IllegalStateException("The Application instance for the current test ($application) is not a NavigationApplication")
if(application is NavigationApplication) return

NavigationComponentBuilder()
.apply { generatedBindings.forEach { it.execute(this) } }
.apply {
runCatching {
if(Class.forName("dagger.hilt.internal.GeneratedComponentManager").isAssignableFrom(application::class.java)) {
plugin(EnroHilt())
}
}
plugin(EnroLogger())
}
.callPrivate<NavigationController>("build")
.apply { callPrivate("installForTest", application) }
}

return navigationApplication.navigationController
internal fun uninstallNavigationController() {
val application = ApplicationProvider.getApplicationContext<Application>()
if(application is NavigationApplication) return
getCurrentNavigationController().callPrivate<Unit>("uninstall", application)
}

fun getCurrentNavigationController(): NavigationController {
val application = ApplicationProvider.getApplicationContext<Application>()
if(application is NavigationApplication) return application.navigationController
return NavigationController.callPrivate("getBoundApplicationForTest", application) as NavigationController
}
}

private fun getGeneratedClasses(): Sequence<Class<*>> {
return Thread.currentThread().contextClassLoader
.getResources("enro_generated_bindings")
.asSequence()
.flatMap {
File(it.file).list().orEmpty().asSequence()
}
.filter {
it.endsWith(".class")
}
.toSet()
.asSequence()
.mapNotNull {
runCatching {
Class.forName("enro_generated_bindings."+it.replace(".class", ""))
}.getOrNull()
}
}

private fun getGeneratedClassesFromDexFile(): Sequence<Class<*>> {
// Here we do some reflection to access the dex files from the class loader. These implementation details vary by platform version,
// so we have to be a little careful, but not a huge deal since this is just for testing. It should work on 21+.
// The source for reference is at:
// https://android.googlesource.com/platform/libcore/+/oreo-release/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
val classLoader = Thread.currentThread().contextClassLoader as BaseDexClassLoader

val pathListField = field("dalvik.system.BaseDexClassLoader", "pathList")
val pathList = pathListField.get(classLoader) // Type is DexPathList

val dexElementsField = field("dalvik.system.DexPathList", "dexElements")

@Suppress("UNCHECKED_CAST")
val dexElements =
dexElementsField.get(pathList) as Array<Any> // Type is Array<DexPathList.Element>

val dexFileField = field("dalvik.system.DexPathList\$Element", "dexFile")
return dexElements
.map {
dexFileField.get(it) as DexFile
}
.asSequence()
.flatMap {
it.entries().asSequence()
}
.filter { it.startsWith("enro_generated_binding") }
.mapNotNull {
runCatching { Class.forName(it) }.getOrNull()
}
}

private fun field(className: String, fieldName: String): Field {
val clazz = Class.forName(className)
val field = clazz.getDeclaredField(fieldName)
field.isAccessible = true
return field
}

private fun <T> Any.callPrivate(methodName: String, vararg args: Any): T {
val method = this::class.java.declaredMethods.filter { it.name.startsWith(methodName) }.first()
method.isAccessible = true
val result = method.invoke(this, *args)
method.isAccessible = false
return result as T
}
2 changes: 2 additions & 0 deletions enro-test/src/main/java/dev/enro/test/EnroTestRule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ class EnroTestRule : TestRule {
}

fun runEnroTest(block: () -> Unit) {
EnroTest.installNavigationController()
val navigationController = EnroTest.getCurrentNavigationController()
navigationController.isInTest = true
try {
block()
}
finally {
navigationController.isInTest = false
EnroTest.uninstallNavigationController()
}
}

Expand Down
16 changes: 15 additions & 1 deletion modularised-example/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

testOptions {
unitTests {
includeAndroidResources = true
}
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
Expand Down Expand Up @@ -61,10 +65,20 @@ dependencies {
kapt 'androidx.hilt:hilt-compiler:1.0.0-beta01'
kapt 'com.google.dagger:hilt-android-compiler:2.31-alpha'

testImplementation "org.robolectric:robolectric:4.4"

implementation 'com.google.android.material:material:1.4.0-alpha01'
testImplementation 'junit:junit:4.13.1'
testImplementation 'androidx.test.ext:junit:1.1.2'
testImplementation 'androidx.test.espresso:espresso-core:3.3.0'
testImplementation project(":enro-test")

testImplementation 'com.google.dagger:hilt-android-testing:2.31-alpha'
kaptTest 'com.google.dagger:hilt-android-compiler:2.31-alpha'

androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
androidTestImplementation project(":enro-test")

}
kapt {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.enro.example.modularised

import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import dev.enro.example.core.navigation.LaunchKey
import dev.enro.test.EnroTestRule
import dev.enro.test.expectOpenInstruction
import dev.enro.test.extensions.getTestNavigationHandle
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
@Config(sdk = [28], application = HiltTestApplication::class)
class ModularisedExampleTest {

@get:Rule
val hiltRule = HiltAndroidRule(this)

@get:Rule
val enroRule = EnroTestRule()

@Test
fun whenMainActivityScenarioIsLaunched_thenLaunchKeyIsOpenedAsReplace() {
val scenario = ActivityScenario.launch(MainActivity::class.java)
scenario.getTestNavigationHandle<MainKey>()
.expectOpenInstruction<LaunchKey>()
}
}

0 comments on commit 8d97efb

Please sign in to comment.