Hilt Testing Best Practices in the MAD Skills series

Second episode of the Hilt MAD Skills series

Eric Chang
Android Developers

--

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:

  1. Annotate your test with @HiltAndroidTest
  2. Add the test rule HiltAndroidRule
  3. Use HiltTestApplication for your Application 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.

Components are generated for each different set of modules.

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.

Organize tests into library Gradle modules when possible.

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!

--

--