Search code examples
androidtestingandroid-jetpack-composeandroid-workmanagerdagger-hilt

Hilt Singleton components instantiated multiple times


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)                                                                                       

Solution

  • 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>())