This is the second article in the MAD Skills series on Hilt. This time we’ll focus on how to write tests using Hilt and some of the best practices to be aware of.

If you prefer to see this in video, you can check it out here.

Hilt’s testing philosophy

Because Hilt is a framework with specific processing principles, its test apis are created with specific goals in mind. Knowing the methods Hilt uses for testing helps you use and understand its API. For more information on testing philosophy, see: Hilt’s Testing Philosophy.

One of the core goals of the Hilt testing API is to reduce the use of unnecessary fake or mock objects in testing while using real objects as much as possible. Real objects can increase test coverage and withstand future changes better than fake or simulated objects. Fake or mock objects are useful when real objects perform expensive tasks, such as IO operations. But they are often overused, and many people use them to solve problems that are conceptually feasible to do in testing.

As a related example, if you use Dagger without Hilt, testing can be very cumbersome. Setting up the Dagger component for testing can require a lot of work and template code, but not using the Dagger and manually instantiating the object can lead to overuse of mock objects. Here’s a look at why.

Manual instantiation (testing without Hilt)

Let’s take a look at an example of why manually instantiating objects in tests can lead to overuse of mock objects.

In the code below, we test the EventManager class with some dependencies. Not wanting to configure the Dagger component for such a simple test, we instantiate the object manually.

class EventManager @Inject constructor(
    dataModel: DataModel,
    errorHandler: ErrorHandler
) {}

@RunWith(JUnit4::class)
class EventManagerTest {
  @Test
  fun testEventManager(a) {
    val eventManager = EventManager(dataModel, errorHandler)

    // Test the code}}Copy the code

At first, since we just call the constructor like Dagger, everything looks pretty simple. But the trouble comes when we need to solve the problem of how to get DataModel vs. ErrorHandler instances:

@RunWith(JUnit4::class)
class EventManagerTest {
  @Test
  fun testEventManager(a) {
    / / er... How to deal with changeNotifier?
    val dataModel = DataModel(changeNotifier)  
    val errorHandler = ErrorHandler(errorConfig)

    val eventManager = EventManager(dataModel, errorHandler)

    // Test the code}}Copy the code

We could also instantiate these objects directly, but if these objects also contain dependencies, then it might be too deep to continue. We might end up calling many constructors before we actually test them. In addition, these constructor calls can make tests vulnerable. Any constructor changes break the test, even if they don’t break anything in production. Changes that should be “no action”, such as changing the parameter order in the @Inject constructor, or adding dependencies to a class through the @Inject constructor, break the test and make it difficult to update.

To avoid this problem, people often just simulate a dependency on DataModel and ErrorHandler. But this is also a problem, because these mock objects are introduced not to avoid any expensive operations in the test, but simply to handle the setup template code for the test.

Use Hilt for testing

When using Hilt, it sets up the Dagger component for you so that you do not need to manually instantiate the object or generate template code when configuring the Dagger in your tests. See the complete test documentation for more tests.

To configure Hilt in your tests, you need:

  • Add the @hiltAndroidTest annotation to your tests
  • Add the test rule HiltAndroidRule
  • Use HiltTestApplication for the Application class

For step 3, how you use HiltTestApplication depends on the type of test you are testing:

  • Refer to the documentation for Robolectric tests.
  • For piling tests, consult the documentation.

Once configured, you can add the @Inject field for your tests to access the binding. These fields are assigned after you call Inject () of HiltAndroidRule, so you can do this in your setup method.

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class EventManagerTest {

  @get:Rule
  val rule = HiltAndroidRule(this)

  @Inject
  lateinit var eventManager: EventManager

  @Before
  fun setup(a) {
    rule.inject(this)}@Test
  fun testEventManager(a) {
    // Test with the injected eventManager}}Copy the code

Note that the injected object must come from the SingletonComponent. If you need objects from ActivityComponent or FragmentComponent, you need to use the regular Android testing API to create an Activity or Fragment and get dependencies from it.

You can then start writing tests. The fields that you inject (in this case, our EventManager class) will be constructed for you by the Dagger just as in a production environment. You don’t have to worry about any template code generated by administrative dependencies.

TestInstallIn

You can use TestInstallIn when you need to replace dependencies during testing, such as real objects doing expensive operations such as calling servers.

You can’t replace a binding directly in Hilt, but you can replace modules through TestInstallIn. TestInstallIn works similarly to InstallIn, except that it also allows you to specify modules that need to be replaced. The replaced module will not be used by Hilt, and any bindings added to the TestInstallIn module will be used. Similar to InstallIn modules, TestInstallIn modules are applied to all tests that depend on them (for example, all tests in Gradle modules).

@Module
@TestInstallIn( components = [SingletonComponent::class], replaces = [BackendModule::class] )
object FakeBackendModule {

  @Singleton
  @Provides
  fun provideBackend(a): BackendClient {
    return FakeBackend.inMemoryBackendBuilder(
              / *... Virtual background data... * /
           ).build()
  }
}
Copy the code

UninstallModules

Use UninstallModules when you need to replace dependencies only in a single test. You can add UninstallModules annotations directly to your tests and specify which modules Hilt should not use.

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@UninstallModules(BackendModule::class)
class DataFetcherTest {

  @BindValue
  valfakeBackend = FakeBackend.inMemoryBackendBuilder(...) .build() ... }Copy the code

In testing, you can add bindings directly using @bindValue or by defining nested components.

TestInstallIn vs UninstallModules

You may be wondering: Which of the two should you use? Here are some comparisons:

TestInstallIn

  • Apply globally
  • Easy to configure
  • It can improve the construction speed

UninstallModules

  • Only for a single test
  • Very flexible
  • Bad for build speed

In general, we recommend starting with TestInstallIn, as it helps speed up builds. You can still use UninstallModules when you really need a separate configuration, but we recommend that you use them sparingly only when you really need them.

TestInstallIn/UninstallModules influence construction speed

Hilt needs to create a new set of components for each different set of modules used for testing. These components can end up being quite large, especially if you rely on modules in a lot of production code.

△ Components generated for different module groups

Each use of UninstallModules adds a new set of components that must be built, and the number of components can grow exponentially based on the number of tests you have. Because TestInstallIn works globally, it joins a default set of components that can be shared across multiple tests. If you can change your tests so that they don’t have to use UninstallModules, you can reduce the set of components that need to be built.

However, sometimes UninstallModules is required for testing. It doesn’t matter! Just be careful and use TestInstallIn by default whenever possible.

Testing depends on

Another way to speed up test builds is to reduce the number of modules and entry points pulled into tests. This section doubles each time you use UninstallModules. Sometimes, the actual coverage of your tests is small, but it may depend on all the production code. Because Hilt cannot determine at compile time what you will test at run time, Hilt must build a component that can find every module and entry point through your dependencies. These modules and entry points can be numerous and can result in a large Dagger component, resulting in an increase in build time.

If you can reduce these dependencies, the new UninstallModules may not be too costly, giving you more flexibility in configuring your tests.

One way to reduce dependencies is to organize your Gradle modules. In this process, you can separate a large number of tests from the Gradle modules of the main application into the Gradle modules of the dependency library, thus reducing the required dependencies.

Where possible, organize tests into dependency library Gradle modules

Organize the Hilt module

Always remember to think about how you organize your Hilt, which can also help you write tests. We often see a very large Dagger module with many bindings, but for Hilt, large modules that do a lot of things just make testing more difficult because you need to replace the entire module instead of individual bindings.

When using Hilt modules, you want to keep them as single-purpose as possible, even by adding only one public binding. This helps readability and makes it easier to replace them in tests if needed.

More resources

Applying these practices and learning more about the trade-offs will help you write Hilt tests more easily. For some of these apis, your choice depends largely on how you set up your application, test, and build systems.

For more information about testing with Hilt, see:

  • Complete documentation
  • Test guide documentation with more examples

That’s all for Hilt testing, but stay tuned for more MAD Skills articles soon.

Please click here to submit your feedback to us, or share your favorite content or questions. Your feedback is very important to us, thank you for your support!