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
- Annotate the test class with
@HiltAndroidTest
- Add the
HiltAndroidRule
test rule and , - 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