InsertKoinIO / koin

Koin - a pragmatic lightweight dependency injection framework for Kotlin & Kotlin Multiplatform
https://insert-koin.io
Apache License 2.0
9.07k stars 718 forks source link

Robolectric App tests get a closed Koin in composables #1557

Closed yschimke closed 1 month ago

yschimke commented 1 year ago

Describe the bug

Using Koin with Robolectric, fails with

Scope '_root_' is closed
org.koin.core.error.ClosedScopeException: Scope '_root_' is closed
    at org.koin.core.scope.Scope.resolveInstance(Scope.kt:221)
    at org.koin.core.scope.Scope.get(Scope.kt:210)
    at org.koin.androidx.viewmodel.factory.KoinViewModelFactory.create(KoinViewModelFactory.kt:25)
    at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.kt:187)
    at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.kt:153)
    at org.koin.androidx.viewmodel.GetViewModelKt.resolveViewModel(GetViewModel.kt:44)

When it fails, these return different Koin instances in the composable

KoinPlatformTools.defaultContext().get() -> the fresh instance
LocalKoinApplication.current -> the instance from the first test

To Reproduce Steps to reproduce the behavior:

  1. Checkout https://github.com/joreilly/Confetti/, specifically this PR https://github.com/joreilly/Confetti/pull/580
  2. Run tests in wearApp/src/test
  3. Disable the overrides in MainActivity
  4. Run tests again

workaround

In main app composable, override

https://github.com/joreilly/Confetti/pull/580/files#diff-0548d0af1b708341bc3c142fbf71b1270fb90f0653f791f2eea9e92867d2e99f

        setContent {
            ConfettiApp(navController, intent)
            // This shouldn't be needed, but allows robolectric tests to run successfully
            // TODO remove once a solution is found or a fix in koin?
            CompositionLocalProvider(
                LocalKoinScope provides KoinPlatformTools.defaultContext().get().scopeRegistry.rootScope,
                LocalKoinApplication provides KoinPlatformTools.defaultContext().get()
            ) {
...

Expected behavior Tests should pass as there is a fresh Koin instance for the current test

Koin project used and used version (please complete the following information): 3.4.0

chankeiro commented 1 year ago

I am experiencing the same issue with koin-androidx-compose:3.4.3. With koin-androidx-compose:3.4.1 + koin-android;3.4.0 everything works fine. The error I get is exactly the same:

Caused by org.koin.core.error.ClosedScopeException: Scope '_root_' is closed
       at org.koin.core.scope.Scope.resolveInstance(Scope.kt:221)
       at org.koin.core.scope.Scope.get(Scope.kt:210)
       at org.koin.androidx.viewmodel.factory.KoinViewModelFactory.create(KoinViewModelFactory.kt:25)
       at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.kt:187)
       at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.kt:153)
       at org.koin.androidx.viewmodel.GetViewModelKt.resolveViewModel(GetViewModel.kt:44)
jjkester commented 1 year ago

I experienced this as well in an Android (connected) test. For me the first test that executes is OK, a subsequent one fails.

Did some debugging and the root cause seems to be the composition locals that Koin uses, LocalKoinApplication and LocalKoinScope.

The first time that these composition locals are read, the values are initialized. This means fetching the current Koin application from the default Koin context. All is fine.

Before starting the second test (using KoinTestRule), a new Koin application is created. However, the composition locals are not initialized again. Hence, any Koin function in Compose is returning an application which has been closed.

Only retrieving the Koin application once is fine for production scenarios, but does not work well for tests.

I found the same workaround as described in the original issue, so that works for me (outside of Robolectric) as well.

Using:

okycelt commented 1 year ago

We're experiencing this issue in production as well. In one scenario, we need to stop koin, start koin with updated modules and restart the main activity.

stopKoin()
startKoin { /* modules */ }

val componentName = ComponentName(context, MainActivity::class.java)
startActivity(Intent.makeRestartActivityTask(componentName))

When creating the new activity, we get the crash when first koinInject() is called.

Caused by: org.koin.core.error.ClosedScopeException: Scope '_root_' is closed
    at org.koin.core.scope.Scope.resolveInstance(Scope.kt:221)
    at org.koin.core.scope.Scope.get(Scope.kt:210)

Workaround described in the original issue seems to be working for us too. We're using:

arnaudgiuliani commented 1 year ago

need to check 👌

arnaudgiuliani commented 1 year ago

@jjkester good catch:

Did some debugging and the root cause seems to be the composition locals that Koin uses, LocalKoinApplication and LocalKoinScope.

The first time that these composition locals are read, the values are initialized. This means fetching the current Koin application from the default Koin context. All is fine.

Before starting the second test (using KoinTestRule), a new Koin application is created. However, the composition locals are not initialized again. Hence, any Koin function in Compose is returning an application which has been closed.

I've introduced LocalKoinApplication and LocalKoinScope to help Compose idiomatic way to handle Scope & Application instance for Koin. Need to check how and why it's running like this with RobotElecltric

jjkester commented 1 year ago

@arnaudgiuliani They run like that for instrumented tests on devices as well (we're not using Robolectric).

I do like the Compose API (koinInject!) by the way, and it works brilliantly in production. However, during tests the lifecycles of things is a bit different. The lifecycle of the Koin application is for example shorter than the lifecycle of the Android application. My guesstimation is that is where the issue is.

As far as I have gathered we're running into some design decision in the compose runtime. The lambda function with which you provide the default value when defining the CompositionLocal is only evaluated once, and Compose will keep the result of that evaluation for as long as possible. I have peeked at the source code of androidx.compose.runtime.CompositionLocal<T> and the factory lambda is ending up in a Kotlin lazy delegate. This is by the way also why reproviding (as in the workaround in the original post) works, because then Compose will actually reevaluate the expression to obtain the Koin context on every recomposition, and when it changes, invalidate everything that uses it so propagate that change.

For us the workaround works fine when testing a whole activity, because we only have to provide a value for the CompositionLocals once in the Activity's setContent, but when testing Composables that use Koin all test cases would need wrapping which is not that nice. Of course there is extension functions to consider, but ideally it should just work.

Compose does offer the LocalContext by default, maybe you can leverage that to rely on the Android Context to retrieve the current KoinApplication, since that should change between tests (at least, for Android instrumented tests).

jjkester commented 1 year ago

I have had a look at the use cases that Koin has for these CompositionLocals. It is clear to me that they serve an important purpose, so refactoring them out does not seem feasible.

To take another direction, I have attempted to create a composable function which will look up the appropriate Koin application and provide it to the composition, based on the workaround we already have in our code and the workaround posted in the first post. It really needs to be tested that it covers all the use cases of Koin, and that it works for the different test frameworks.

The first one was not feasible for me to have a look at today (if at all - I will need some pointers!), and the second one would introduce a whole new class of tests to this repository (instrumented tests are not straightforward to run on a CI pipeline) so that does require some thinking (or input from @arnaudgiuliani) before attempting it in my opinion.

Code can be found in my fork: https://github.com/jjkester/koin/commit/7bf2a846fbbed376f89223a2b6c9300776e60fa1

arnaudgiuliani commented 1 year ago

yes interesting @jjkester the KoinApplication composable function already exist in the common koin-compose module, would require your findContextForKoin function to be sure to catchup the right instance

jjkester commented 1 year ago

Indeed, that function already exists but the difference being that the application already has been defined outside of Compose.

The context function has been based on this one from Accompanist, difference being that it is fine to fall back on the application context as that is the root one.

arnaudgiuliani commented 1 year ago

do you want to make a PR? else I can add it directly. That implies forcing to use KoinApplication composable function. Still checking if it becomes necessary or not.

jjkester commented 1 year ago

I'm happy to create a PR. I do have some more ideas though, because as I tried to explain earlier this may not cover all use cases (mainly testing standalone composables using ComposeContentTestRule).

This solution does feel like a workaround, but I'm not confident there is a real, practical solution. (I'm also not confident that there is none at all...). So maybe a bit more investigation is warranted.

If I would be contributing the PR I do want to accompany it with a (regression) test suite to show that this indeed solves the issue. Not sure if it is sufficient to try and replicate the behaviour of the test frameworks, or whether it would be better to create actual tests using these actual frameworks. But as I mentioned before, we should consider where to put those and how to run them (especially the Android instrumented tests). Do you have any ideas about that?

arnaudgiuliani commented 1 year ago

yes, still wondering if it's a "robotelectric" case only of if this has to be part of the API design 🤔 I'm checking your PR

okycelt commented 1 year ago

yes, still wondering if it's a "robotelectric" case only of if this has to be part of the API design 🤔 I'm checking your PR

@arnaudgiuliani, for us it's not only a robolectric case, see https://github.com/InsertKoinIO/koin/issues/1557#issuecomment-1512604377

jjkester commented 1 year ago

I am not using Robolectric, so for sure not only an issue with that. Also with the Android Instrumented tests.

While running Android tests the application process stays alive, and the compositionLocal is not re-initialized after the test rule created a new Koin application. Wondering whether this is the same on Compose multiplatform actually, but I have no experience with that. Might write a small test case in my PR.

arnaudgiuliani commented 1 year ago

ok great

gdesantos commented 1 year ago

Any news about this?

arnaudgiuliani commented 1 year ago

still on hold, need to follow up for next release 👍

Kevinrob commented 1 year ago

ℹ️ We also have this crash on production. We let the user switch the language in-app and we stop/start Koin.

Our workaround is to restart the full process with ProcessPhoenix: ProcessPhoenix.triggerRebirth(context)

arnaudgiuliani commented 1 year ago

koin-compose 1.1.0 & koin-androidx-compose 3.5.0 will bring KoinContext & KoinAndroidContext to setup compose with the right CompositionLocalProvider, depending on the current context (default context or android one)

61a88bbf79d593c9ed777f5b1acb07caa5e6db2e

Let's see how it goes with this.

We can replace your snippet with KoinAndroidContext @yschimke

mr-kew commented 4 months ago

Soo, is this fixed?

I am trying to startKoin in @Before fun and stopKoin in @After fun on Koin 3.6.0-wasm-alpha2. I am getting this exact exception: org.koin.core.error.ClosedScopeException: Scope '_root_' is closed. I don't understand from this thread what should I do.

ben-gooding-sky commented 4 months ago

@mr-kew You need to wrap tests in either the KoinContext or KoinAndroidContext Composeables, so they can provide access to the correct KoinContext

mr-kew commented 4 months ago

@ben-gooding-sky Thanks for quick response. It's working brilliantly.

KoinAndroidContext type does not exist for me though, but that may be because I'm on multiplatform?

Should I then wrap my composables inside setContent in MainActivity with KoinContext too or would that cause problems? I am doing startKoin inside onCreate there.

wagarcdev commented 2 months ago

@arnaudgiuliani

I was also having this issue, and was not using Roboeletric... same as @jjkester

Tried KoinContex and KoinAndroidContext wrappers in test without result...

so I dig into it and found a probable cause:

The Docs indicates this test rule:

class KoinTestRule(
    private val modules: List<Module>
) : TestWatcher() {
    override fun starting(description: Description) {
        startKoin {
            androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
            modules(modules)
        }
    }

    override fun finished(description: Description) {
        stopKoin()
    }
}

This is making subsequent instrumented tests to fail

The fix is to use a rule like this:

class KoinTestRule(
    private val modules: List<Module>
) : TestWatcher() {
    override fun starting(description: Description) {

        if (getKoinApplicationOrNull() == null) {
            startKoin {
                androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
                modules(modules)
            }
        } else {
            loadKoinModules(modules)
        }
    }

    override fun finished(description: Description) {
        unloadKoinModules(modules)
    }
}
arnaudgiuliani commented 1 month ago

regression spotted in https://github.com/InsertKoinIO/koin/issues/1900