Chapters

Hide chapters

Dagger by Tutorials

First Edition · Android 11 · Kotlin 1.4 · AS 4.1

19. Testing With Hilt
Written by Massimo Carli

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

In Chapter 17, “Hilt — Dagger Made Easy”, you learned that one of Hilt’s main goals is to make testing easier. In the first chapter, you also learned that simplifying testing is also one of the main reasons to use dependency injection.

Using constructor injection, you can create instances of classes to test, then pass mocks or fakes directly to them as their primary constructor parameters. So where does Hilt come in? How does it make tests easier to implement and run?

To understand this, think about what Dagger and Hilt actually are. Dagger gives you a declarative way to define the dependency graph for a given app. Using @Modules and @Components, you define which objects to create, what their lifecycles are and how they depend upon one other.

Hilt makes things easier with a predefined set of @Components and @Scopes. In particular, using @HiltAndroidApp, you define the main @EntryPoint for the app bound to the Application lifecycle. You do the same with ActivityComponent, ServiceComponent and others you met in the previous chapters. In general, you define a dependency graph containing the instances of classes you inject.

Here’s the point. The objects you use when you run a test are, in most cases, not the same objects you use when running the app. The dependency graph isn’t the same.

Hilt gives you an easy way to decide which objects are in the dependency graph for the app and which objects are there for the tests.

In this chapter, you’ll learn how Hilt helps you implement tests for your app. In particular, you’ll see how to use Hilt to run:

  • Robolectric UI tests
  • Instrumentation tests

Hilt only supports Robolectric tests and instrumentation tests because with constructor injection, it’s easy to implement unit tests without Dagger. You’ll see how shortly.

To learn how to implement tests with Hilt, you’ll work on the RandomFunNumber app. This simple app lets you push a button to get a random number and some interesting facts about that number. It’s also perfect for learning about testing with Hilt.

Note: As you know, Hilt is still in alpha version and so are its testing libraries. During this chapter, you’ll implement some configuration caveats that need to be there to make the tests run successfully. Some of these solve bugs in the library that might be resolved by the time you read this chapter.

The RandomFunNumber app

In this chapter, you’ll implement tests for RandomFunNumber. To start, use Android Studio to open the RandomFunNumber project from the starter folder in this chapter’s materials. When you open the project, you’ll see the structure in Figure 19.1:

Figure 19.1 — Initial Project Structure
Figure 19.1 — Initial Project Structure

As you can see, this app uses some of the modules you already used in the previous chapters.

Build and run to see the screen in Figure 19.2:

Figure 19.2 — Initial Empty Screen
Figure 19.2 — Initial Empty Screen

Click the RANDOM NUMBER button and you’ll see a screen like in Figure 19.3:

Figure 19.3 — A possible result
Figure 19.3 — A possible result

The output in your case is probably different because, when you press the button, you generate a random value and send a request to numbersapi.com to get some useful information about it. Every time you click the button, you’ll get a different number and a different description.

This is a very simple app that contains everything you need to learn how to write tests using the tools and API Hilt provides. Before doing that, however, you’ll learn about RandomFunNumber’s:

  • Architecture
  • Hilt configuration

After you understand how the app works, you’ll start writing tests.

RandomFunNumber’s architecture

The class diagram in Figure 19.4 gives you a high-level description of RandomFunNumber’s main components:

Figure 19.4 — RandomFunNumber class diagram
Mahese 07.1 — FibbaqKecPesqir ccefx joozcuk

The Hilt configuration

RandomFunNumber only contains a few components. It has a relatively simple Hilt configuration, which you can see in the diagram in Figure 19.5:

Figure 19.5 — RandomFunNumber dependency diagram
Fuxolu 13.8 — ZuxkatJuwPiyjik xozepbuwkq huocxay

Implementing RandomFunNumber’s tests

You have the background you need to use RandomFunNumber to practice implementing tests with the utilities Hilt provides. In the following paragraphs, you’ll learn how to:

Defining the project structure for testing

Use Android Studio to open the starter project from the materials for this chapter. Next, select Project View and look at the build types for the app module in Figure 19.6:

Figure 19.6 — App module build types
Bapeku 36.2 — Ush yulade leedb ktlar

Figure 19.7 — Classes shared between tests
Pizeze 86.4 — Dcispec yhaxip rucjaep kufpt

Implementing unit tests with constructor injection

As you learned in the previous chapters of this book, constructor injection makes tests easier to write because you simply create an instance of the object to test and pass some fakes or stubs to it as parameters of its primary constructor.

Figure 19.8 — Create a test for FunNumberViewModel
Xomore 16.6 — Ghuifu e keqj ves QidRulhagFuoqGosir

Figure 19.9 — Select the JUnit 4 option
Galagu 32.8 — Voqizh zje TUhiq 5 owxoec

Figure 19.10 — Select the test building type
Jawawa 42.25 — Rasuxt lwi wacr luefrudx wvgu

class FunNumberViewModelTest {
}
class FunNumberViewModelTest {

  @Rule
  @JvmField
  val instantExecutorRule = InstantTaskExecutorRule() // 1

  private lateinit var objectUnderTest: FunNumberViewModel // 2
  private lateinit var funNumberService: FakeFunNumberService // 3

  @Before
  fun setUp() {
    funNumberService = FakeFunNumberService()
    objectUnderTest = FunNumberViewModel(funNumberService) // 4
  }

  @Test
  fun `when refreshNumber invoked you observe FunNumber`() {
    val expectedFunNumber = FunNumber(
        88,
        "Testing Text",
        true,
        "default"
    )
    funNumberService.resultToReturn = expectedFunNumber
    objectUnderTest.refreshNumber() // 5
    val result = objectUnderTest.numberFunFacts.getOrAwaitValue() // 6
    assertEquals(expectedFunNumber, result) // 7
  }
}
Figure 19.11 — Run FunNumberViewModelTest
Resiti 08.48 — Mow MucTaswefGaogQayafZohn

Figure 19.12 — FunNumberViewModelTest success
Waxito 70.40 — WofRubmacMoumWisacFoln gufkoxt

Using Robolectric & Hilt for UI tests

Robolectric (http://robolectric.org/) is a testing framework that lets you implement and run tests that depend on the Android environment without an actual implementation of the Android platform. This allows you to run UI tests on the JVM without creating instances of the Android emulator. In this way, tests run quickly and require fewer resources.

Installing the Hilt testing library for Robolectric

For your first step, you need to add the dependencies for Robolectric’s Hilt testing library. Open build.gradle from app and add the following definition:

// ...
dependencies {
  // ...
  // Hilt for Robolectric tests.
  testImplementation "com.google.dagger:hilt-android-testing:$hilt_version" // 1
  kaptTest "com.google.dagger:hilt-android-compiler:$hilt_version" // 2
}

Creating a MainActivity test with Robolectric & Hilt

Your first test will cover MainActivity. Open MainActivity.kt in the ui package of the app module and you’ll see:

@AndroidEntryPoint // 1
class MainActivity : AppCompatActivity() {

  @Inject
  lateinit var navigator: Navigator // 2

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    if (savedInstanceState == null) {
      navigator.navigateTo( // 3
        FragmentDestination(FunNumberFragment(), 
        R.id.anchor_point)
      )
    }
  }
}
class RoboMainActivityTest
@HiltAndroidTest // 1
@Config(application = HiltTestApplication::class) // 2
@RunWith(RobolectricTestRunner::class) // 3
class RoboMainActivityTest {

  @get:Rule
  var hiltAndroidRule = HiltAndroidRule(this) // 4

  @Before
  fun setUp() {
    hiltAndroidRule.inject() // 5
  }

  @Test
  fun whenMainActivityLaunchedNavigatorIsInvokedForFragment() { // 6
    assertTrue(true)
  }
}
sdk=28
sdk=28
application=dagger.hilt.android.testing.HiltTestApplication # HERE

Testing MainActivity with Robolectric & Hilt

In the previous section, you created an empty test to verify the Hilt configuration for Robolectric. Now, it’s time to create the actual test.

Configuring ActivityScenario

ActivityScenario is part of an API Google provides for testing Activitys. To use this, you need to add the following code to RoboMainActivityTest.kt, which you created earlier:

@HiltAndroidTest
@Config(application = HiltTestApplication::class)
@RunWith(RobolectricTestRunner::class)
class RoboMainActivityTest {

  @get:Rule(order = 0) // 2
  var hiltAndroidRule = HiltAndroidRule(this)

  @get:Rule(order = 1) // 2
  var activityScenarioRule: ActivityScenarioRule<MainActivity> = 
      ActivityScenarioRule(MainActivity::class.java) // 1
  // ...
    @Test
  fun whenMainActivityLaunchedNavigatorIsInvokedForFragment() {
    activityScenarioRule.scenario // 3
  }
}
kotlin.UninitializedPropertyAccessException: lateinit property navigator has not been initialized
./gradlew testDebugUnitTest --tests "*.RoboMainActivityTest.*"

Replacing the real Navigator with a fake

Now, you’ll replace the actual Navigator implementation with a fake one. To achieve this, add the following code:

@HiltAndroidTest
@Config(application = HiltTestApplication::class)
@RunWith(RobolectricTestRunner::class)
@UninstallModules(ActivityModule::class) // 1
class RoboMainActivityTest {
  // ...
  @BindValue // 2
  @JvmField
  val navigator: Navigator = FakeNavigator() // 3

  @Test
  fun whenMainActivityLaunchedNavigatorIsInvokedForFragment() {
    activityScenarioRule.scenario
    val fakeNav = navigator as FakeNavigator
    assertNotNull(fakeNav.invokedWithDestination)
    assertTrue(fakeNav.invokedWithDestination is FragmentDestination<*>) // 4
  }
}

Reviewing your achievements

Great! You implemented your first test using Hilt and Robolectric. You also learned that you can:

Implementing instrumented tests with Hilt & Espresso

In the previous section, you used Robolectric and Hilt to implement a UI test for MainActivity. Now, you’ll try another option for testing with Hilt — running an instrumentation test with Espresso.

Adding Hilt testing dependencies

To use the Hilt testing library in the instrumentation tests, you need to add the following definition to build.gradle in app:

// ...
dependencies {
  // Hilt for instrumented tests.
  androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" // 1
  kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" // 2
  // ...
}

Creating an Activity for testing

First, you need to create an empty Activity that uses @AndroidEntryPoint. Start by creating a folder for the debug build type at the same level as the existing ones. Create a java folder in it and add a package named com.raywenderlich.android.randomfunnumber. In that package, create a new file named HiltActivityForTest.kt and add the following code:

@AndroidEntryPoint // HERE
class HiltActivityForTest : AppCompatActivity()
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.raywenderlich.android.randomfunnumber">

  <application>
    <activity
      android:name=".HiltActivityForTest"
      android:exported="false" />
  </application>
</manifest>
Figure 19.13 — Debug build type structure
Xotuti 50.57 — Jikuv moays hmhi pmxejgaqe

Implementing a utility to launch the Fragment

As mentioned earlier, you need a way to launch a Fragment using the HiltActivityForTest you implemented as its container.

inline fun <reified T : Fragment> launchFragmentInHiltContainer(
    fragmentArgs: Bundle? = null,
    @StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
    crossinline action: Fragment.() -> Unit = {}
) {
  // ...
}

Implementing a custom AndroidJUnitRunner

As you learned when testing with Robolectric, you need to specify the TestRunner for your tests. To do this, you just need to create a custom TestRunner implementation.

class HiltTestRunner : AndroidJUnitRunner() {

  override fun newApplication(
      cl: ClassLoader?,
      className: String?,
      context: Context?
  ): Application {
    return super.newApplication(
        cl,
        HiltTestApplication::class.java.name, // HERE
        context)
  }
}
// ...
android {
  // ...
  defaultConfig {
    applicationId "com.raywenderlich.android.randomfunnumber"
    minSdkVersion 28
    targetSdkVersion 30
    versionCode 1
    versionName "1.0"
    testInstrumentationRunner "com.raywenderlich.android.randomfunnumber.runner.HiltTestRunner" // HERE
  }
  // ...
}
// ...

Implementing FunNumberFragment’s test

You now have everything you need to implement the test for FunNumberFragment. Following the process in Figure 19.8-10, create FunNumberFragmentTest.kt in the androidTest build type and add the following code:

@HiltAndroidTest
@UninstallModules(ActivityModule.Bindings::class) // 1
class FunNumberFragmentTest {

  @get:Rule
  var hiltAndroidRule = HiltAndroidRule(this) // 2

  @BindValue
  @JvmField
  val funNumberService: FunNumberService = FakeFunNumberService() // 3

  @Before
  fun setUp() {
    hiltAndroidRule.inject() // 4
  }

  @Test
  fun whenButtonPushedSomeResultDisplayed() {
    (funNumberService as FakeFunNumberService).resultToReturn =
        FunNumber(123, "Funny Number", true, "testValue")
    launchFragmentInHiltContainer<FunNumberFragment>() // 5
    onView(withId(R.id.refresh_fab_button)).perform(click()) // 6
    onView(withId(R.id.fun_number_output)).check(matches(withText("123")))
    onView(withId(R.id.fun_fact_output)).check(matches(withText("Funny Number")))
  }
}

Replacing an entire @Module

In the previous example, you only replaced some of the bindings you installed as part of a @Module. For instance, in RoboMainActivityTest, you uninstalled ActivityModule, but you added a binding for Navigator.

@HiltAndroidTest
@UninstallModules( // 1
    SchedulersModule::class,
    NetworkModule::class,
    ApplicationModule::class)
class FunNumberServiceImplHiltTest {

  @Inject
  lateinit var objectUnderTest: FunNumberServiceImpl

  @Inject
  @IOScheduler
  lateinit var testScheduler: Scheduler // 2

  @BindValue
  @JvmField
  val funNumberEndPoint: FunNumberEndpoint = StubFunNumberEndpoint() // 3

  @BindValue
  @JvmField
  val randomGenerator: NumberGenerator = FakeNumberGenerator().apply { // 3
    nextNumber = 123
  }

  @get:Rule
  var hiltAndroidRule = HiltAndroidRule(this)

  @Before
  fun setUp() {
    hiltAndroidRule.inject()
  }

  @Test
  fun whenRandomFunNumberIsInvokedAResultIsReturned() {
    val fakeCallback = FakeCallback<FunNumber>()
    objectUnderTest.randomFunNumber(fakeCallback)
    (testScheduler as TestScheduler).advanceTimeBy(100, TimeUnit.MILLISECONDS)
    val received = fakeCallback.callbackParameter
    Assert.assertNotNull(received)
    if (received != null) {
      with(received) {
        assertEquals(number, 123)
        assertTrue(found)
        assertEquals(text, "Number is: 123")
        assertEquals(type, "validType")
      }
    } else {
      Assert.fail("Something wrong!")
    }
  }

  @Module
  @InstallIn(ApplicationComponent::class) // 4
  object SchedulersModule {

    @Provides
    @ApplicationScoped
    @MainScheduler
    fun provideMainScheduler(): Scheduler = Schedulers.trampoline()

    @Provides
    @ApplicationScoped
    @IOScheduler
    fun provideIoScheduler(): Scheduler = TestScheduler()
  }
}

Key points

  • Hilt provides a testing library for Robolectric and instrumented tests.
  • You don’t need Dagger to implement unit tests if you use constructor injection.
  • Hilt allows you to replace parts of the app’s dependency graph for testing purposes.
  • Using @HiltAndroidTest, you ask Hilt to generate a dependency graph to use during the execution of a test.
  • You can remove bindings from the dependency graph using @UninstallModules and replace some of them using @BindValue.
  • You can replace all the bindings for a @Module by uninstalling it with @UninstallModules and installing a new @Module.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now