Hilt Testing Best Practices in the MAD Skills series
Second episode of the Hilt MAD Skills series
This is the next MAD Skills article on Hilt! This time we’ll be focusing on how to write tests with Hilt and some of the best practices to be aware of.
If you prefer to consume this content in a video format, check it out here:
Hilt Testing Philosophy
Since Hilt is a more opinionated framework, the Hilt testing APIs are built with certain goals in mind. Knowing the approach Hilt takes toward testing will make the APIs a lot easier to use and understand. You can read more about the testing philosophy in depth here.
One of the core goals of the testing APIs in Hilt is to avoid unnecessary usage of fakes or mocks in tests and to use real objects as much as possible instead. Real objects will increase test coverage and withstand changes over time much better than a fake or a mock. Fakes or mocks are useful when the real object is doing expensive tasks like IO, but they are often overused to cover up other problems where there isn’t anything that conceptually can’t be done in the test.
One of these problems is that using Dagger without Hilt can be cumbersome in tests. Setting up Dagger components for tests can be a lot of work and boilerplate, but the alternative of not using Dagger and just manually instantiating objects leads to an overuse of mocks. Let’s take a look at why that is.
Manual instantiation (Testing without Hilt)
To see why manually instantiating objects in tests leads to overusing mocks, let’s take a look at an example.
Below, we have a test for an EventManager
class that has some dependencies. Since we don’t want to set up Dagger components for this simple test, we just manually instantiate the object instead.
This seems simple enough at first since we’re just calling the constructor like Dagger would, but problems arise when we need to figure out how to get an instance of our DataModel
and ErrorHandler
.
We could just instantiate those as well, but if those classes also have dependencies, then this could start to get pretty deep. We may just end up calling a lot of constructors before we even get to the actual test. The other issue is that all of these constructor calls will make the test brittle. Any change to the constructors here may break the test, even if they wouldn’t have broken anything in production. Changes that should be a no-op, like reordering arguments in the @Inject
constructor or adding a dependency on a class with an @Inject
constructor, will break tests and be a pain to update.
So to avoid that, many people often just mock the dependencies DataModel
and ErrorHandler
. This is a problem though because those mocks were introduced not to avoid anything expensive in the test, but just to deal with test setup boilerplate.
Testing with Hilt
With Hilt, the Dagger components are set up for you so you can avoid manual instantiation and avoid the boilerplate of setting up Dagger in your tests. For the full testing documentation, see here.
Setting up Hilt in your tests requires you to:
- Annotate your test with
@HiltAndroidTest
- Add the test rule
HiltAndroidRule
- Use
HiltTestApplication
for yourApplication
class
For the third step, how to use HiltTestApplication
will depend on the type of test, so see the instructions for Robolectric tests here and for instrumentation tests here.
With that set up, you’re now able to add @Inject
fields to your test to access bindings. These fields will be set after you call inject()
on the HiltAndroidRule
, so you’ll likely want to do that in your setup method.
One thing to be aware of is that injected objects have to come from the SingletonComponent
. If you need something from the ActivityComponent
or FragmentComponent
, you’ll need to use the regular Android testing APIs to create an activity or fragment and grab dependencies off of them.
After that, you can just write your test since your injected field, in this case our EventManager
class, will be constructed by Dagger just like in production. There’s no need to worry about any boilerplate from managing dependencies!
TestInstallIn
When you have cases where you need to replace a dependency in a test, for example when the real object does do something expensive like a call to a server, you can use TestInstallIn
to make that change.
While you can’t directly replace a single binding in Hilt, TestInstallIn
does allow you to replace modules. TestInstallIn
works similarly to InstallIn
, except it also lets you specify a module that it should replace. The replaced module won’t be used by Hilt and instead any binding added into the TestInstallIn
module will be used. Similar to InstallIn
modules, TestInstallIn
modules will apply to all tests that depend on them (e.g. all of the tests in the Gradle module).
UninstallModules
When you have a situation where you only want to make a replacement in a single test, you can instead use UninstallModules
. UninstallModules
is placed on the test directly and allows you to specify which modules Hilt shouldn’t use.
In the test, you can add bindings directly with @BindValue
or by defining nested modules.
TestInstallIn vs UninstallModules
So you may be wondering, which of these should you use? Here is a quick comparison of the two:
TestInstallIn
- Applies globally
- Easier configuration
- Better for build speed
UninstallModules
- Single test only
- Greater flexibility
- Worse for build speed
Generally, we recommend starting with TestInstallIn
because it is better for build speed. UninstallModules
can still be used when you do need a separate configuration, but it is recommended to use it sparingly and only when specifically needed.
Why TestInstallIn/UninstallModules affects build speed
For each different set of modules used for a test, Hilt has to create a new set of components. These components can end up being quite large especially if you depend on a lot of modules from your production code.
Each usage of UninstallModules
adds a new set of components that has to be built, which can quickly multiply depending on the number of tests you have. On the other hand, since TestInstallIn
applies globally, it goes in the shareable default set of components that can be reused for multiple tests. If you can change a test so that it doesn’t have to use UninstallModules
, then you can save on a set of components being built.
Sometimes a test will need to use UninstallModules
though, and that is okay! Just be aware of the tradeoff and default to using TestInstallIn
wherever possible.
Test dependencies
Another way to reduce build speed of tests is to address the other axis and reduce the modules and entry points being pulled into the tests. This is the part that gets multiplied by each usage of UninstallModules
. Sometimes, your test may depend on all of your production code when it is actually only testing a very small portion of the code. Since Hilt can’t tell at compile time what you’re going to be testing at runtime, Hilt has to build a component that has every module and entry point it can find via your deps. This can be a lot and might be contributing to very large Dagger components that add to your build time.
If you can trim these dependencies down, then each new usage of UninstallModules
might not be as costly, which may give you more flexibility when configuring your tests.
One way you can do this is to organize your Gradle modules so that a lot of your tests are not in the main app Gradle module but are instead in separate library Gradle modules to reduce dependencies.
Organizing Hilt Modules
One thing to keep in mind that will also help you write tests is to consider how you organize your Hilt modules. It is pretty common to see very large Dagger modules that have a lot of bindings, but with Hilt, having large modules that do a lot of things may make testing harder because you have to replace whole modules, not individual bindings.
When making modules in Hilt, try to keep them to a single purpose, maybe even with just one public binding. This helps readability and makes it easier to replace them in tests if needed.
More Resources
Applying these practices and knowing more about the tradeoffs will hopefully help you have an easier time writing tests with Hilt. For some of these tradeoffs, which way you choose to do things will depend a lot on how your app, tests, and build system are set up today.
For more information about testing with Hilt, you can check out the full documentation here. There’s also a testing guide here with more examples.
That’s it for Hilt testing, but keep an eye out for more MAD Skills episodes coming up!