Dagger Hilt Learnings

This is a loose list of learnings I had when I first came in contact with Dagger Hilt, especially in regards to testing. So, without further ado, let’s get into it.

Documentation

While the documentation on Dagger Hilt on developer.android.com is already quite exhaustive, I figured I missed a couple of important information and gotchas that I only got from the official Hilt documentation. So be sure you read through both thoroughly.

Scoping

It’s buried a bit in the documentation, but it should be remembered that predefined components won’t mean that all dependencies in the particular component are single instances. Remember that there is a corresponding scope for each and every component type that ensures that there is only one specific instance of your thing. This is particularly useful if your thing holds some kind of shared state:

@Module
@InstallIn(ActivityRetainedComponent::class)
object RetainedModule {
    @Provides
    @ActivityRetainedScope
    fun provideFlow() = 
        MutableStateFlow<@JvmSuppressWildcards SomeState>(SomeState.Empty)
}

Communication between different Android ViewModel instances come into my mind where this is handy.

Since scoping comes with an overhead, also remember that you can use @Reusable in any component in case you only want to ensure that there is some instance of your otherwise stateless dependency at a time:

@Module
@InstallIn(SingletonComponent::class)
object SingletonModule {
    @Provides
    @Reusable
    fun provideHttpClient() = OkHttpClient.Builder()...
}

Dagger Hilt Debugging

Dagger Hilt is – under the hood – a beefed up Dagger that comes with a couple of interesting features, like isolated dependency graphs for tests. But after all, it’s still just Dagger and implemented in Java. Which means your usual rules for making Dagger work apply here (@JvmSuppressWildcards for the rescue when dealing with generics, etc.), just with an extra level of complexity that hides the usual unreadable errors.

Since most of my issues resolved around understanding the removal / replace of test dependencies, I figured the entry point for Hilt’s generated test sources is build/generated/hilt/component_sources. This directory is split into two parts, one that contains the generated test components for your tests, one for each test class, underneath component_sources/{variant}UnitTest/dagger/hilt/android/internal/testing/root and one that collects an injector implementation, again, for each of your tests, residing in component_sources/{variant}UnitTest/package/path/to/your/tests.

The former directory is definitely the more interesting one, because you can check each generated test component if it carries the correct modules you want your test to provide, i.e. you can check if your modules are properly replaced via @TestInstallIn or removed via @UninstallModules.

Component Tests have to be Android Tests

I like to write blackbox component tests on the JVM for REST or DAO implementations. Sometimes this requires a complex dependency setup (mappers, libraries, parsers, …) where I’d like to use Dagger to create instances of my subject under test.

Dagger Hilt supports this, kind of, as long as you don’t care that you rewrite your JUnit 5 component test in JUnit 4 (including all Extensions you might have written). Reason is that even though your test doesn’t need a single Android Framework dependency, you still need to run it with Robolectric because this is the only supported way of using Hilt in JVM tests as of now:

Even though we have plans in the future for Hilt without Android, right now Android is a requirement so it isn’t possible to run the Hilt Dagger graph without either an instrumentation test or Robolectric.

Eric Chang

UI Testing : Activity

Using Dagger Hilt for an Activity test is straight forward, you basically follow the documentation:

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
internal class SomeActivityTest {
    
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)
    
    @get:Rule(order = 1)
    val activityScenarioRule = ActivityScenarioRule(SomeActivity::class)
    
    @Inject lateinit var dep: SomeDep
    
    @Before
    fun init() {
        hiltRule.inject()
    }
    
    @Test
    fun someTest() {
         // stub dep
         ...
         // launch
         activityScenarioRule.launchActivity()
    }
}

This works nicely in case your dependency is in Singleton scope, because your test instance itself cannot inject anything else but Singleton-scoped dependencies, but what if not and we have to stub the aforementioend MutableStateFlow?

Now, Hilt has a concept called EntryPoints that we can define in a test-local manner. The entry point then targets a specific component and can fetch dependencies from that. To find the right component for your entry point it helps looking at the component hiearchy. If our dependency lives in the ActivityRetainedComponent, its as easy as creating a new entry point into this for our test, right?

    ...
    
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)
    
    @EntryPoint
    @InstallIn(ActivityRetainedComponent::class)
    internal interface ActivityRetainedEntryPoint {
       val flow: MutableStateFlow<@JvmSuppressWildcards SomeState>
    }
    
    @Before
    fun init() {
        hiltRule.inject()
    }
    ...

Wrong. To get an instance of the entry point, you have to call EntryPoints.get(component, ActivityRetainedEntryPoint::class), where the component is the instance of the thing the component is owned, i.e. an Application instance for SingletonComponent entry points, an Activity instance for ActivityComponent entry points, aso. But what is the thing that owns the ActivityRetainedComponent and where to we get access to it?

Turns out we don’t need it. Looking at the component hiearchy again we see that ActivityComponent, FragmentComponent and a few others are direct or indirect child components of the ActivityRetainedComponent and therefor see all of it’s dependencies. So we “just” need an Activity or Fragment instance to get our dependency.

The Hilt docs state that the easiest way is to define a custom static activity class in your code, like this

@AndroidEntryPoint
class TestActivity : AppCompatActivity() {
    val flow: MutableStateFlow<SomeState>
}

but that Activity needs to go through the lifecycle at first to be usable. Can’t we just use the Activity instance we launch anyways for this? Turns out we can, we just need to “extract” the actual Activity instance from the ActivityScenario:

fun <T : Activity> ActivityScenario<T>.getActivity(): T? {
    val field = this::class.java.getDeclaredField("currentActivity")
    field.isAccessible = true
    @Suppress("UNCHECKED_CAST")
    return field.get(this) as? T?
}

inline fun <reified E : Any> ActivityScenarioRule<*>.getActivityEntryPoint(): E =
    EntryPoints.get(
        getScenario().getActivity() ?: error("activity not started"),
        E::class.java
    )

so our complete test looks like this:

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
internal class SomeActivityTest {
    
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)
    
    @get:Rule(order = 1)
    val activityScenarioRule = ActivityScenarioRule(SomeActivity::class)
    
    @EntryPoint
    @InstallIn(ActivityComponent::class)
    internal interface EntryPoint {
       val flow: MutableStateFlow<SomeState>
    }
    
    @Before
    fun init() {
        hiltRule.inject()
    }
    
    @Test
    fun someTest() {
         // launch
         activityScenarioRule.launchActivity()
         // get the flow and do things with it
         val flow = activityScenarioRule.getActivityEntryPoint<EntryPoint>().flow
    }        
}

Downside is now, of course, that the Activity must be launched (started even!) before one gets access to the dependency. Can we fix that? Unfortunately not without moving the Dependency up the component hierarchy and installing the original module that provided it. See Replacing Ad-hoc Dependencies for a way to do that.

UI Testing : Fragments

The first issue with Hilt-enabled Fragment testing is that there is no support for Hilt-enabled Fragment testing. The problem is that the regular androidx.fragment:fragment-testing artifact comes with an internal TestActivity that is not Hilt-enabled, so we have to write our own:

@AndroidEntryPoint(AppCompatActivity::class)
class TestHiltActivity : Hilt_TestHiltActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        val themeRes = intent.getIntExtra(THEME_EXTRAS_BUNDLE_KEY, 0)
        require(themeRes != 0) { "No theme configured for ${this.javaClass}" }
        setTheme(themeRes)
        super.onCreate(savedInstanceState)
    }

    companion object {
        private const val THEME_EXTRAS_BUNDLE_KEY = "theme-extra-bundle-key"

        fun createIntent(context: Context, @StyleRes themeResId: Int): Intent {
            val componentName = ComponentName(context, TestHiltActivity::class.java)
            return Intent.makeMainActivity(componentName)
                .putExtra(THEME_EXTRAS_BUNDLE_KEY, themeResId)
        }
    }
}

This is basically copied from the original TestActivity and adapted. I place this into a separate Gradle module, because like the original artifact, this has to become a debugImplementation dependency.

Now we need a separate FragmentScenario and FragmentScenarioRule as well, to use this new Activity. I’ll not paste the complete implementation for them here, but refer you to this gist where I collected them.

With FragmentScenario we have more control over the Fragments state in which it is launched. My implementation by default launches a Fragment in Lifecycle.State.INITIALIZED, basically the state in which the Fragment is right after it’s instantiation and – more importantly – after Hilt injected its dependencies!

So, we can now stub dependencies that are used during onCreate like so:

@EntryPoint
@InstallIn(FragmentComponent::class)
internal interface FragmentEntryPoint {
    val someFlow: MutableStateFlow<SomeState>
}

private val fragmentScenarioRule = HiltFragmentScenarioRule(SomeFragment::class)
private val entryPoint by lazy {
    fragmentScenarioRule.getFragmentEntryPoint<FragmentEntryPoint>()
}

...
val fragmentScenario = fragmentScenarioRule.launchFragment(R.style.AppTheme)
entryPoint.someFlow.tryEmit(SomeState.SomeValue)               
fragmentScenario.moveToState(Lifecycle.State.RESUMED)

Replacing Ad-hoc Dependencies

There are times where you don’t provision dependencies through specific modules that you could, on the test side of things, replace via @TestInstallIn or alike. A good example for this are UseCase classes.

I tend to test my View (Fragment or Activity) together with my Android ViewModel implementation and the latter makes use of these UseCase classes to interface to my domain layer. Naturally one wants to replace the UseCase implementation with a fake implementation or a mock, but how can one accomplish this with Hilt?

Turns out it’s quite easy – all you have to do is to @BindValue your dependency in your test class. A dependency provisioned through this seems to take precendence over constructor-injected ad-hoc dependencies:

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
internal class SomeActivityTest {

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

    @get:Rule(order = 1)
    val activityScenarioRule = ActivityScenarioRule(SomeActivity::class)

    @BindValue
    val useCase: MyUseCase = mockk()

    @Before
    fun init() {
        hiltRule.inject()
    }
    ...
}

Lifecycle and Scoping in Tests

More often than not you might stumble in weird test issues when you follow the “good citizen” rule and provision even your test dependencies (e.g. mocks) with @Reusable. In some cases you might end up with two different instances, one in your test and one in your production code.

So, spare yourself a few headaches and and just always annotate those test dependencies with the scope matching the component, e.g. @Singleton.

Module Size

The ability to uninstall certain modules per test has the nice “side-effect” of training yourself to make your modules smaller, because the larger a module is – and the more unrelated dependencies it provisions, the more work you have to do to provide the “other” dependencies you’re not interested in once you uninstall that module for a particular test case.

Well, at least Dagger tells you that something is missing, by printing out it’s beloved compilation errors, right?!

Global Test Modules

Sometimes you want to remove some dependency from your graph that would otherwise go havoc during testing, think of a Crashlytics module suddenly sending crash reports on test failures or a Logging module that prints garbage to your stdout. Usually you’d do something like this then:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [LoggingModule::class]
)
internal object TestLoggingModule {
    @Provides
    @Singleton
    fun provideLogger(): Logger = Logger { /* no-op * / }
}

All fine, but what if you now have a single test case where you want to check the log output? Well, you can’t uninstall a module installed via @TestInstallIn, but you can do a workaround: Install a module that removes the dependency, then add another regular module that adds your no-op implementation:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [LoggingModule::class]
)
internal object TestLoggingRemovalModule

@Module
@InstallIn(SingletonComponent::class)
internal object TestLoggingModule {
    @Provides
    @Singleton
    fun provideLogger(): Logger = Logger { /* no-op * / }
}

Now, in your test you can remove that module and have a custom implementation that you can verify against:

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@UninstallModules(TestLoggingModule::class)
@Config(application = HiltTestApplication::class)
internal class SomeLoggingTest {
    @BindValue
    val logger: Logger = MyFakeLogger()
    ...
}

Code Coverage

If your @AndroidEntryPoints don’t show up in Jacoco’s code coverage reports as covered, even though you have tests for them, follow this excellent post and choose whether you want to keep using the Hilt Gradle plugin or not.

Wrap-up

Dagger Hilt makes testing a lot easier; the ability to replace dependencies for each test separately is a real game changer.

What’s also true is that it is still Dagger, i.e. the configuration is complex, the error messages cryptic at best and – this is new (at least for me) – Hilt compilation issues have occasionally to be fixed by cleaning your module, because there seem to be issues with incremental compilation. Not neccessarily confidence-inspiring, but at least you know how to fix things.

I hope I could help you out with some my learnings, let me know what you think!