System Testing Android Dependencies with Hilt

When I’ve needed to system test an Android application using Hilt (Android’s de-facto dependency injection framework), I’ve found many of the proposed solutions to be convoluted, outdated, or requiring anti-patterns.

Over time, I’ve put together a relatively light-weight approach to mocking core functionality in system tests with Hilt. I’ll be using Kotlin, but the same concepts will apply with Java.

Dependency Injection & System Tests

Dependency injection allows us to decouple the creation of objects from their usage. Among other benefits of using dependency injection is testability. When unit testing a component, we can simply inject mock dependencies without needing to know anything about the dependencies’ responsibilities.

The more complicated case is working with system tests. In a system test, you might want to mock a low-level component that drives core functionality of the app. For example, consider a timer that controls some core logic. If you can’t mock out that timer, then your test will rely on system time. Relying on system time in tests is rarely a tenable solution. This can make testing even the simplest user interactions quite expensive.

Timers and Tests

In the example below we have the class MyTimer being injected into the MainActivity using Hilt. Imagine an implementation where start initiates a 30-second timer that calls onStop when completed. The onStop function causes the side effect of updating some text on screen.

class MyTimer @Inject constructor(someDependency: MyDependency) {
  fun start() { // start a 30 second timer that calls onStop on completion }
  fun onStop() { // update some text in the UI }
}
@AndroidEntryPoint
class MainActivity: AppCompatActivity() {
  @Inject lateinit var timer: MyTimer
  ...
}

This is a simple use of Hilt that should look familiar. The MainActivity can now use an instance of MyTimer, and it never needs to know anything about MyTimer’s dependencies.

Now let’s write a system test that will exercise the MainActivity. This example is adapted from Android’s developer guides. The test case syntax is Espresso; the test case should read mostly like English, and the rest is boring configuration.

@RunWith(AndroidJUnit4::class)
class SystemTest {
  @get:Rule 
  var activityRule: ActivityScenarioRule = ActivityScenarioRule(MainActivity::class.java)

  @Before
  fun setup() { ... }

  @Test   
  fun observeChangeCausedByTimer() { 
    onView(withId(R.id.timer_change_target))
      .check(matches(withText(“Initial State”)))

    onView(withId(R.id.start_timer_button))
      .perform(click())

    // sleep for 30 seconds

    onView(withId(R.id.timer_change_target))
      .check(matches(withText(“Updated State”)))
  }
}

This test will take at least 30 seconds to pass! That’s because we are still using all of the original dependencies of MainActivity including the 30 seconds of system time.

Hilt Test Config

Now let’s discuss how we can use Hilt to inject different implementations of MainActivity’s original dependencies. But before we can get into the testing, we’ll need to do a little configuration.

First, several dependencies will need to be added to the application level build.gradle file. I’m using version 2.28-alpha, which was the most recent version when I encountered this problem; you may want to consider adapting to a newer version. Also note that you’ll want to use androidTestAnnotationProcessor in place of kaptAndroidTest if you’re using Java.

dependencies {
  …
  androidTestImplementation 'com.google.dagger:hilt-android-testing:2.28-alpha'
  kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.28-alpha'
}

Next, we’ll need to define a custom test runner and configure it with Gradle. I dislike that this generic boilerplate is necessary, but I haven’t been able to find a better approach. Below is the simplest possible example that I’ve found to work.

class CustomTestRunner : AndroidJUnitRunner() {
  override fun newApplication(
    cl: ClassLoader?,
    className: String?,
    context: Context?
  ): Application {
    return super.newApplication(cl, HiltTestApplication::class.java.name, context)
  }
}
android {
  …
  defaultConfig {
    …
    testInstrumentationRunner "com.example.package.CustomTestRunner"
  }
}

Now we can return to the system test file and finalize the test setup:

  • Replace the @RunWith(...) annotation with @HiltAndroidTest. This tells the test that it will need to use Hilt to inject some dependencies.
  • Replace the ActivityScenarioRule declaration with a HiltRule like this: @get:Rule var hiltRule = HiltAndroidRule(this).
  • Include an ActivityScenario with the line lateinit var mainScenario: ActivityScenario.
  • Add two lines at the bottom of the setup function: instruct Hilt to inject dependencies with hiltRule.inject(), and start up the activity with injected dependencies with mainScenario = ActivityScenario.launch(MainActivity::class.java).

Hilt Modules

That concludes the boring test setup work, but we still haven’t mocked out a single dependency. Before jumping into that, we’ll need a basic understanding of Hilt components and modules. In an overly simplified sense, modules are containers that describe to Hilt how to provide various dependencies. Components are lifecycle-scoped containers for modules. For our purposes, we’re going to be able to define a module for our dependency that can be swapped out for another at test time.

First, we’ll need to define an interface for MyTimer to ensure that the real implementation and the mock conform to the same structure. Make sure to declare that your class conforms to the interface.

interface IMyTimer
  fun start()
  fun onStop()
}
class MyTimer @Inject constructor(someDependency: MyDependency): IMyTimer {
  override fun start() { … }
  override fun onStop() { … }
}

Next, we define the Hilt Module and install it within an appropriate component. I’m using the ApplicationComponent (deprecated and renamed in later versions to SingletonComponent), so that the timer is available throughout the lifecycle of the entire application. (A more specific component lifecycle may be more appropriate for your use-case; see the component lifecycle docs for more details.)

Keep in mind that modules can contain multiple bindings if you need to mock multiple similarly scoped dependencies for your system tests. The @Binds annotated function’s return type declares the interface that this function will create instances of and the parameter is the implementation of that interface to use. That took me a minute to wrap my head around, so maybe re-read that last sentence.

@Module @InstallIn(ApplicationComponent::class)
abstract class TimerModule {
  @Binds abstract fun bindMyTimer(myTimer: MyTimer): IMyTimer
}

Mocks and Modules

At this point, the injection should still work as expected, and we are ready to mock a dependency for system testing. First, let’s define our mock to conform to the IMyTimer interface.

class FakeMyTimer @Inject constructor(): IMyTimer {
  override start() { … mock behavior … }
  override onStop() { … mock behavior … }
}

Now the key is to tell Hilt to uninstall the TimerModule and replace it with a TestTimerModule that provides a FakeMyTimer implementation. Uninstalling a module is done with another annotation at the top of your system test class: @UninstallModules(TimerModule::class). Then we can define a module just like we did before, swapping in the mock implementation. Be sure to use the same component in this module.

@UninstallModules(TimerModule::class)
@HiltAndroidTest
class SystemTest {
  @get:Rule 
  var hiltRule = HiltAndroidRule(this)

  lateinit var mainScenario: ActivityScenario

  @Module @InstallIn(ApplicationComponent::class)
  abstract class TestTimerModule {
    @Binds abstract fun bindMyTimer(myTimer: FakeMyTimer): IMyTimer
  }

  @Before
  fun setup() {
    hiltRule.inject()
    mainScenario = ActivityScenario.launch(MainActivity::class.java)
  }

  @Test   
  fun observeChangeCausedByTimer() { 
    …
  }
}

That’s all there is to it! Now with an appropriate timer mock implementation, this system test can likely be run in a second or less. A little bit of boilerplate is necessary, but I have found that, overall, it simplifies the system testing experience and improves performance a lot. Other system-level dependencies can be added to that same module or assigned their own.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *