Testing on android with Hilt

Testing on android with Hilt

After exploring and using the dagger-hilt dependency injection, I went further to learn how use it when writing test cases for my apps. Comparing hilt with other dependency injection libraries, it makes dependency injection much easier reducing boilerplate codes. It also makes testing code easier. Testing with Hilt does not require maintenance since new set of components are automatically generated for each test.

Hilt only supports android instrumented tests and roboelectric tests and it is not necessary for unit tests because Hilt is not necessarily needed to instantiate a class that uses constructor injection. However To write test cases for these type of classes, you can directly call a class constructor by passing a fake or mock dependencies. For integration tests , Hilt injects dependencies as it would on production code.

To use Hilt for testing, you will need appropriate dependencies and follow these three steps

  1. Annotate the test class with @HiltAndroidTest
  2. Add the HiltAndroidRule test rule and ,
  3. Use HiltTestApplication for your base android application class
@HiltAndroidTest
class FooTest {
  @get:Rule 
  val hiltRule = HiltAndroidRule(this)
..
}

HiltAndroidRule is used to manage the state of components and perform dependency injection on test classes.@HiltAndroidTest is responsible for generating Hilt components on UI tests.

Testing Room Database

A typical Android Studio project contains two source sets for test cases, androidTest and test directories. Here the latter contains tests that runs on real or virtual devices while the former contains test that run on the local machine. Since the room database requires the context of an android device, the test case will be in the androidTest source set.

@Dao
interface NoteDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertNoteItem(noteItem: NoteItem)
    @Delete
    suspend fun deleteNoteItem(noteItem: NoteItem)
    @Query("SELECT * FROM note_items")
    fun observeAllNoteItems(): LiveData<List<NoteItem>>
}

This code snippet represents a Dao that provides an interface to our model class. In order to test this interface , a reference to the app database id required. An instance is also required in order to let hilt know how to provide dependency injection.Here is a blog I wrote about getting started with Hilt

@Module
@InstallIn(ApplicationComponent::class)
object TestAppModule{
    @Provides
    @Named("test_db")
/**Hold database in RAM instead of persistence storage**/
    fun provideInMemoryDb(@ApplicationContext context: Context) = Room.inMemoryDatabaseBuilder(context,AppDatabase::class.java)
        .allowMainThreadQueries()
        .build()
}

Here is a test case for the database operations i.e insert, delete and query

@Runwith(AndroidJUnit4::class)
class NoteDaoTest{
    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    @Inject
    @Named("test_db")
    lateinit var database:AppDatabase
    lateinit var  dao:NoteDao

@Before
fun setup(){
hiltRule.inject()
dao= database.noteDao()
  }
@After
fun teardown(){
    database.close()
  }
@Test
fun insertNoteItem() = runBlockingTest{
 val noteItem= NoteItem("Title", "Body", id=1)
      dao.insertNoteItem(noteItem)
      val allNoteItems = dao.observeAllNoteItems().getOrAwaitValue()
      assertThat(allNoteItems).contains(noteItem)
  }
@Test
fun deleteNoteItem()=runBlockingTest{
   val noteItem= NoteItem("Title", "Body", id=1)
   dao.insertNoteItem(noteItem)
   dao.deleteNoteItem(noteItem)
   val allNoteItems = dao.observeAllNoteItems().getOrAwaitValue()
   assertThat(allNoteItems).doesNotContain(noteItem)
 }
}

function setup() is annotated with @Before to initialize the database and inject dependencies. Functions annotated with @After are used to release resources allocated at @Before. In this case , it is to close the database.

When using Hilt the base application class is annotated with @HiltAndroidApp however when writing test cases the application class is not very suitable therefore a custom application class is needed, that extends AndroidJUnitRunner

class HiltTestRunner:AndroidJUnitRunner(){
  override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

The build.gradle file , app module can then be configured as below.

defaultConfig{
...
testInstrumentationRunner'com.noteapp.app.HiltTestRunner'
}

Writing UI Tests

Any UI test must be annotated with @HiltAndroidTest so as to generate the Hilt components for the test.Each test has to request bindings from its Hilt components.These binding can be of SingletonComponent bindings , ActivityComponent bindings or FragmentComponent bindings.SingletonComponent binding can be injected directly into a test by annotating a field with @Inject and for injection to occur HiltAndroidRule must be called.

@HiltAndroidTest
class fooTest{
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var foo:Foo  
@Test
fun foo{
....
hiltRule.inject()
...
  }
}

ActivityComponent binding requires an instance of Hiltactivity. here .FragmentComponent binding can also be accessed in a similar way as activity's. Since Hilt does not support FragmentScenario it is impossible to use the launchFragmentInContainer from androidx library for testing because it relies on an activity that is not annotated with @AndroidEntryPoint. An alternative to this is to use launchFragmentInHiltContainer .

Image by Alexander Lesnitsky from Pixabay