Hilt Singleton components instantiated multiple times
Asked Answered
L

2

6

In my instrumentation tests I have noticed that my Retrofit components are created before the test even does the hiltRule.inject() command.

This is probably because I'm using WorkManager and early entry point components

open class BaseApplication : Application(), Configuration.Provider {

    override fun getWorkManagerConfiguration(): Configuration {
        return Configuration.Builder().setWorkerFactory(
            EarlyEntryPoints.get(
                applicationContext,
                WorkerFactoryEntryPoint::class.java
            ).workerFactory
        ).build()
    }

    @EarlyEntryPoint
    @InstallIn(SingletonComponent::class)
    interface WorkerFactoryEntryPoint {
        val workerFactory: HiltWorkerFactory
    }
}

@CustomTestApplication(BaseApplication::class)
interface HiltTestApplication

I want to inject an OkHttp3 MockWebServer in my tests and also in the Retrofit interceptors so that I can determine which port is being used (from mockWebServer.start()) and set up my mocks accordingly but, despite marking my MockWebServer wrapper class as a Singleton I can see multiple instances of it being created, which therefore have different port numbers.

It looks like it creates one instance of MockWebServer when the application is created and then another when the test is injected but presumably this means that my mocks aren't correctly defined.

@Singleton
class MockWebServerWrapper @Inject constructor() {

    private val mockWebServer by lazy { MockWebServer() }

    val port get() = mockWebServer.port

    fun mockRequests() {
        ...
    }
}

Is there a more correct way to share the same mock webserver between my Retrofit Interceptors defined for the WorkManager and those needed for network requests within the test activity itself?

After the comments from Levon below, I made the changes to BaseApplication, created the ApplicationInjectionExecutionRule and updated the BaseTest class so that the rules read like this:

@get:Rule(order = 0)
val disableAnimationsRule = DisableAnimationsRule()

private lateinit var hiltRule: HiltAndroidRule

@get:Rule(order = 1)
val ruleChain: RuleChain by lazy {
    RuleChain
        .outerRule(HiltAndroidRule(this).also { hiltRule = it })
        .around(ApplicationInjectionExecutionRule())
}

@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<MainActivity>()

But I was still seeing the errors for the (Urban) Airship takeoff which is why I'd move the WorkManagerConfiguration to EarlyEntryPoints to begin with.

E  Scheduler failed to schedule jobInfo com.urbanairship.job.SchedulerException: Failed to schedule job at com.urbanairship.job.WorkManagerScheduler.schedule(WorkManagerScheduler.java:31)
Caused by: kotlin.UninitializedPropertyAccessException: lateinit property workerFactory has not been initialized at com.gocitypass.BaseApplication.getWorkManagerConfiguration(BaseApplication.kt:33)                                                                                       
Literati answered 1/3, 2023 at 14:50 Comment(0)
I
7

When running instrumentation tests, the Hilt’s predefined Singleton component’s lifetime is scoped to the lifetime of a test case rather than the lifetime of the Application. This is useful to prevent leaking states across test cases.

Typical Application lifecycle during an Android Gradle instrumentation test

  • Application created
    • Application.onCreate() called
    • Test1 created
      • SingletonComponent created
      • testCase1() called
    • Test1 created
      • SingletonComponent created
      • testCase2() called ...
    • Test2 created
      • SingletonComponent created
      • testCase1() called
    • Test2 created
      • SingletonComponent created
      • testCase2() called ...
  • Application destroyed

As the lifecycle above shows, Application#onCreate() is called before any SingletonComponent can be created, so injecting binding from Hilt’s predefined Singleton component inside the Application is impossible when running an instrumentation test. To bypass this restriction, Hilt provides an escape hatch(EarlyEntryPoint) to request bindings in the Application even before the Hilt’s predefined Singleton component is created.

Using EarlyEntryPoint comes with some caveats. As you mentioned, a singleton scoped binding retrieved via the EarlyEntryPoint and the same binding retrieved from Hilt’s predefined Singleton component retrieves different instances of the singleton scoped binding when running instrumentation tests.

Luckily Hilt provides OnComponentReadyListener API, which can be registered in a custom test rule, and it will notify once the Hilt Singleton component is ready. This allows us to delay the injection execution code in BaseApplication and run it in the test rule. EarlyEntryPoints in BaseApplication can be changed to EntryPoints now, as we don’t try to access a binding before the Singleton component is created in the instrumentation tests.

BaseApplication.kt

    open class BaseApplication : Application(), Configuration.Provider {

        private lateinit var workerFactory: HiltWorkerFactory

        override fun onCreate() {
            super.onCreate()
            if (!isUnderAndroidTest()) {
                excecuteInjection()
            }
        }

        fun excecuteInjection() {
            workerFactory = EntryPoints.get(
                applicationContext,
                WorkerFactoryEntryPoint::class.java
            ).workerFactory
        } 
    
        override fun getWorkManagerConfiguration(): Configuration {
            return Configuration.Builder().setWorkerFactory(workerFactory).build()
        }
    
        @EntryPoint
        @InstallIn(SingletonComponent::class)
        interface WorkerFactoryEntryPoint {
            val workerFactory: HiltWorkerFactory
        }

        @Suppress("SwallowedException")
        private fun isUnderAndroidTest(): Boolean {
            return try {
                Class.forName("androidx.test.espresso.Espresso")
                true
            } catch (e: ClassNotFoundException) {
                false
            }
        }
    }

ApplicationInjectionExecutionRule.kt

import androidx.test.core.app.ApplicationProvider
import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread
import dagger.hilt.android.testing.OnComponentReadyRunner
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

class ApplicationInjectionExecutionRule : TestRule {

    private val targetApplication: BaseApplication
        get() = ApplicationProvider.getApplicationContext()

    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            override fun evaluate() {
                OnComponentReadyRunner.addListener(
                    targetApplication, WorkerFactoryEntryPoint::class.java
                ) { entryPoint: WorkerFactoryEntryPoint ->
                    runOnUiThread { targetApplication.excecuteInjection() }
                }
                base.evaluate()
            }
        }
    }
}

Note that the test rule using OnComponentReadyListener will work as expected only if HiltAndroidRule runs first, like

@Rule
@JvmField
val ruleChain = RuleChain
    .outerRule(hiltRule)
    .around(ApplicationInjectionExecutionRule())

Edit: it seems that HiltAndroidRule is not set as the first rule to run, can you try

val hiltRule = HiltAndroidRule(this)

@Rule
@JvmField
val commonRuleChain = RuleChain
    .outerRule(hiltRule)
    .around(ApplicationInjectionExecutionRule())
    .around(DisableAnimationsRule())
    .around(createAndroidComposeRule<MainActivity>())
Instanter answered 13/3, 2023 at 20:59 Comment(6)
Hi Levon, thanks for your very comprehensive answer. It's helped my understanding of the issue a lot. Unfortunately when I try your changes I'm still seeing issues with the WorkManager tasks—in particular (Urban) Airship tasks that complain that lateinit property workerFactory has not been initialized in BaseApplication.ktLiterati
Hey Barry, can you also share your test class after the changes?Instanter
I tried a copy of variations but this was how I was setting the rules in the last attempt @get:Rule(order = 0) val disableAnimationsRule = DisableAnimationsRule() private lateinit var hiltRule: HiltAndroidRule @get:Rule(order = 1) val ruleChain: RuleChain by lazy { RuleChain .outerRule(HiltAndroidRule(this).also { hiltRule = it }) .around(ApplicationInjectionExecutionRule()) } @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule<MainActivity>()Literati
Would you mind sharing it in the question for better visibility?Instanter
HI Levon, I'd actually worked out another way to suppress the Airship worker jobs during startup anyway but I can confirm that your latest commonRuleChain works even with them unsuppressed but using EntryPoints instead of EarlyEntryPoints. Thank you very much for your help. I'm now able to have a singleton mockwebserver with a different port each test instead of always fixed to 8080 which I think is better!Literati
Yay, glad it worked out :)Instanter
A
0

Based on Levon's answer, you can create a new CustomHiltAndroidRule class like this:

class CustomHiltAndroidRule(
    private val testInstance: Any,
    private val hiltRule: HiltAndroidRule = HiltAndroidRule(testInstance)
) : TestRule by RuleChain
    .outerRule(hiltRule)
    .around(ApplicationInjectionExecutionRule())

And use it like this in your test:

@get:Rule(order = 0)
val hiltRule = CustomHiltAndroidRule(this)

The hilt rule is exposed as a property, in case you need it to call hiltRule.inject() somewhere, just make it public and use it.

Absinthism answered 2/6, 2023 at 11:17 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.