google/dagger

`TestInstallIn` doesn't replace the dependencies when being used to replace dependencies in a `SingletonComponent` for robolectric tests running on a class in a dynamic feature module

techeretic opened this issue · 5 comments

So, we have a complicated dagger2 app structure along with multiple dynamic feature modules.
I have created a sample project : https://github.com/techeretic/HiltRobolectricIssue that explains this issue.
Before I dive into the details, let me give some background about the app

  1. We have a dagger2 graph being created in the Application.attachBaseContext method.
override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        earlyInitComponent = DaggerEarlyInitComponent.factory().create()
  1. The dependencies from dagger graph created in step 1 are bound to the hilt SingletonComponent
@[Module InstallIn(SingletonComponent::class)]
object FirstModule {
    @Provides
    fun provideEarlyInitDependency(): EarlyInitDependency {
        return MainApplication.getInstance().earlyInitComponent.getEarlyInitDependency()
    }
  1. We define a DynamicFeatureDependencies EntryPoint which we'll used to share dependencies with the dagger component in the dynamic feature module
@[EntryPoint InstallIn(SingletonComponent::class)]
interface DynamicFeatureDependencies {
    fun getSingletonInterface(): SingletonInterface
}
...
...
@[DFM Component(
    dependencies = [DynamicFeatureDependencies::class],
    modules = [DynamicFeatureModule::class]
)]
interface DynamicFeatureComponent {

Now, since robolectric tests aren't supported in a dyamic feature modules, we create a separate Android Library just for running the robolectric tests. This library depends on the app module and the dynamic feature module.

In robolectrictests module, we have configured a robolectric test runner to run with the a custom app (because we have some custom setups being done in our implementation of the Application class).

open class TestRobolectricTestRunner(cls: Class<*>) : RobolectricTestRunner(cls),
    GlobalConfigProvider {

    // We need it this way in our project
    override fun buildGlobalConfig(): Config {
        return Config.Builder()
            // This is a little hokey but it allows us to set our Robolectric SDK version in a single place.
            .setSdk(Config.Builder().setSdk(28).build().sdk[0])
            .setApplication(TestApplication::class.java)
            .build()
    }
    override fun get(): Config = Config.Builder().setSdk(28).build()
}

and TestApplication is

class TestApplication : MainApplication() {

    lateinit var dynamicFeatureDependencies: DynamicFeatureDependencies

    override fun onCreate() {
        super.onCreate()
        dynamicFeatureDependencies = EntryPoints.get(this, DynamicFeatureDependencies::class.java)
    }

We have a SingletonModule that provides an implementation of SingletonInterface

const val DEFAULT_VALUE = 10

class SingletonDependency : SingletonInterface {
    override val someValue: Int
        get() = DEFAULT_VALUE

For testing, we setup a test implementation of the SingletonInterface for our robolectric test

@[Module TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [SingletonModule::class]
)]
object TestSingletonModule {
    @[Provides Singleton]
    fun provideSingletonDependency(): SingletonInterface {
        return object : SingletonInterface {
            override val someValue: Int
                get() = TEST_VALUE

            override fun doSomething(context: Context) {
                // No Op
            }
        }
    }
}

const val TEST_VALUE = 100

Eventually, when we run the test as

@RunWith(TestRobolectricTestRunner::class)
class ExampleUnitTest {
    @Inject
    lateinit var singletonInterface: SingletonInterface

    @Before
    fun setUp() {
        DaggerRobolectricDynamicFeatureComponent.factory()
            .create(
                EntryPoints.get(TestApplication.getInstance(), DynamicFeatureDependencies::class.java)
            )
            .inject(this)
    }

    @Test
    fun addition_isCorrect() {
        assertEquals(singletonInterface.someValue, TEST_VALUE)
    }
}

It fails.

expected:<10> but was:<100>
Expected :10
Actual   :100
<Click to see difference>

Thus, the TEST_VALUE which is being set in TestSingletonModule is never part of the hilt singleton dagger graph.

So, either this TestInstallIn doesn't work when using custom application implementation (not the @CustomTestApplication) and EntryPoints used for sharing dependencies with a Dagger2 graph in a dynamic feature module.

(Note: Code snippets here are from a project https://github.com/techeretic/HiltRobolectricIssue that mimics our app code.)

@TestInstallIn only works with @HiltAndroidTest, but since your TestApplication extends your MainApplication you are using it with @HiltAndroidApp. The reason this works this way is that the way we distinguish test vs prod is with those annotations on the root (they generate different code in the Application). You'll have to use the @CustomTestApplication (https://dagger.dev/hilt/testing#custom-test-application) to write your test.

How do I use @CustomTestApplication with our application implementation?

@CustomTestApplication forces to be used on an interface which doesn't have the dagger graph that we need to be created in attachBaseContext. This dagger graph feeds into a module that is used with the hilt singleton component.

@CustomTestApplication takes in a base class as an argument to the annotation. So you can use @CustomTestApplication(MainApplication::class) and the test application we generate will subclass your MainApplication.

Actually, as written, you'll need to do a little refactoring, which is to separate the @HiltAndroidApp part off of the MainApplication since it is incompatible with @HiltAndroidTest. So something like...

@HiltAndroidApp
class MainApplication : MainApplicationImpl

// This class has all of the current logic with your custom Dagger component
open class MainApplicationImpl : Application

@CustomTestApplication(MainApplicationImpl::class)
interface CustomTest

Then setup your tests to use the generated CustomTest_Application

Ah! Lemme try it out.

I was attempting

@CustomTestApplication(MainApplication::class)
interface CustomApplication

and ran into this

public abstract interface CustomApplication {
                ^
  @CustomTestApplication value cannot be annotated with @HiltAndroidApp. Found: com.bug.hiltrobolectricissue.MainApplication
  [Hilt] Processing did not complete. See error above for details.

FAILURE: Build failed with an exception.

Thanks for explaining that I have to separate it out.

That fixed it!

Thanks!

I've updated the Example project with the changes that fixed it. See commit here techeretic/HiltRobolectricIssue@22ff8e9

One thing to call out is that I had to move out all the field injection from the MainApplication to the class that has the @HiltAndroidApp. Though this can worked around.

I'll continue with our app migration to hilt from dagger 2