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