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 Extension
s 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 EntryPoint
s 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 Fragment
s 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 @AndroidEntryPoint
s 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!