Dagger-Hilt(이하 Hilt)와 같은 DI(Dependency Injection, 의존성 주입) 프레임워크를 사용하여 얻을 수 있는 장점 중 하나는 코드를 더 쉽게 테스트할 수 있다는 점이다. 테스트하려는 각 컴포넌트, 모듈들은 독립적으로 테스트 되어야 한다. 하지만 각 컴포넌트 들은 상호간의 관계, 즉 의존성을 가지고 각자의 역할을 수행하거나 필요한 역할을 상대에게 위임하게 된다.
테스트 시에는 이런 역할을 수행할 의존성 대상을 직접 생성할 수도 있지만, 의존성 주입 프레임워크인 Hilt를 사용하여 좀 더 쉽고 빠르게 의존성을 주입하여 테스트를 간편화 할 수 있다.
이번 글에서는 테스트 시 Hilt를 적용하는 방법 중 간단한 내용에 대해 정리하고자 한다. 그 후 Hilt를 이용한 Testing 시 필요한 지식과 다음 글에서 Hilt를 이용한 복잡한 UI(Activity, Fragment 등)를 쉽게 테스트 하는 방법에 대해서도 정리한다.
테스트 저장소
테스트 코드를 모두 작성한 내용은 아래 저장소를 참고하길 바란다. 브랜치는 testing_with_hilt 이다.
필요한 의존성 라이브러리
테스트 시에는 다양한 라이브러리가 필요하지만 Dagger-Hilt를 테스트에 이용시에 필요한 의존성을 아래와 같다. 아래의 의존성을 build.gradle (App 레벨)에 추가해 준다.
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44'
(2022.11.08 현재 기준으로 버전 2.44가 최신 버전이다.)
테스트 대상과 목적
테스트 하려는 대상은 Dao (Data Access Object) 이다. Dao를 얻으려면 Database에 의존성을 필요로 한다. 따라서 Hilt를 이용하여 Dao에 Database (InMemory) 객체의 의존성을 주입해주려 한다.
처음 Hilt를 사용하지 않은 테스트의 @Before를 확인해보자. database를 Room을 이용하여 인메모리 데이터베이스를 생성하여 database 인스턴스를 생성한다. dao는 생성된 database에서 얻어오게 된다.
우리는 이 database를 Hilt를 이용하여 DI하는 방식으로 변경할 것이다.
@ExperimentalCoroutinesApi
@SmallTest
class MarsPhotoDaoTest {
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var database: MarsPhotoItemDatabase
private lateinit var dao: MarsPhotoDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MarsPhotoItemDatabase::class.java
).allowMainThreadQueries().build()
dao = database.marsPhotoDao()
}
...
}
테스트에서 Hilt를 사용하기 위해서
테스트에서 Hilt를 사용하기 위해서 아래와 같은 요소가 필수이다.
- Test에 @HiltAndroidTest, 애노테이션을 추가한다.
- 테스트 룰에 HiltAndroidRule 을 추가한다.
- 안드로이드 어플리케이션 클래스를 위해 HiltTestApplication 을 사용한다.
이 요소들은 설명을 하면서 하나씩 추가를 할 것이다.
의존성 주입 대상과 테스트 시 어떻게?
Room을 이용한 Database 객체 생성 시 프로젝트의 사용하던 모듈이 있을 것이다. 해당 모듈의 코드를 먼저 살펴본다. DatabaseModule이란 이름으로 MarsPhotoItemDatabase 객체를 생성하여 주입할 수 있도록 모듈화 되어 있다.
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Singleton
@Provides
fun provideMarsPhotoItemDatabase(
@ApplicationContext context: Context
) = Room.databaseBuilder(context, MarsPhotoItemDatabase::class.java, DATABASE_NAME).build()
}
그렇다면 테스트 시 필요한 모듈 코드는 어떤지 아래 코드를 보자. @Module 애노테이션은 익숙할테니 설명은 스킵하고, TestDatabaseModule 오브젝트를 살펴보자. 기존 코드는 Room을 이용하여 Database를 생성하는데, 테스트 시에는 인메모리 데이터베이스를 생성하여 테스트를 진행한다.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class]
)
object TestDatabaseModule {
@Provides
fun provideInMemoryDb(@ApplicationContext context: Context) =
Room.inMemoryDatabaseBuilder(context, MarsPhotoItemDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@TestInstallIn 애노테이션?
기존 코드와 다른 점은 @InstallIn 대신 @TestInstallIn를 사용하였다. 애노테이션의 구성요소는 아래와 같다. 특히 replaces를 주목하자.
components |
Returns the component(s) into which the annotated module will be installed.
|
replaces |
Returns the InstallIn module(s) that the annotated class will replace in tests.
|
@TestInstallIn으로 주석이 달린 Dagger 모듈을 통해 사용자가 지정된 소스 집합의 모든 테스트에 대한 기존 @InstallIn 모듈. 예를들면, 위처럼 DatabaseModule을 TestDatabaseModule로 대체한다. TestDatabaseModule에 주석을 달면 이 작업을 수행할 수 있습니다.
즉 MarsPhotoItemDatabase의 인스턴스를 DatabaseModule이 아닌 TestDatabaseModule에 의해 주입되어야 하므로 @TestInstallIn 애노테이션을 사용한다.
Hilt Application 정의하기.
Hilt를 안드로이드 프로젝트에 적용하기 위해서 Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 주석이 지정된 어플리케이션 클래스를 포함해야 한다.
@HiltAndroidApp은 애플리케이션 수준 종속 항목 컨테이너 역할을 하는 애플리케이션의 기본 클래스를 비롯하여 Hilt의 코드 생성을 트리거한다.
테스트에서는 @HiltAndroidApp 애노테이션을 사용할 수는 없다. 그래서 아래와 같은 방식으로 AndroidJUnitRunner를 상속받은 클래스에서 newApplication() 함수를 오버라이드한다.
오버라이드 한 함수에서 super.newApplication에서 HiltTestApplication을 전달함으로써 @HiltAndroidApp과 동일한 역할을 수행할 수 있도록 한다.
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
추가로 이 어플리케이션이 Test를 위함임을 build.gradle에 아래와 같이 명시해야 한다. 명시하는 위치는 defaultConfig 내부에 testInstrumentationRunner에 설정한다.
defaultConfig {
applicationId "com.linuxias.setup_for_testing"
minSdk 30
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "com.linuxias.setup_for_testing.HiltTestRunner"
}
테스트 코드
@ExperimentalCoroutinesApi
@SmallTest
@HiltAndroidTest
class MarsPhotoDaoTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Inject
lateinit var database: MarsPhotoItemDatabase
private lateinit var dao: MarsPhotoDao
@Before
fun setup() {
/*
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MarsPhotoItemDatabase::class.java
).allowMainThreadQueries().build()
*/
hiltRule.inject()
dao = database.marsPhotoDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun insertTestEntity() = runTest {
val testEntity = MarsPhotoItem(1000, "id", "type", "resource_link", 1)
dao.insertMarsPhotoItem(testEntity)
val allTestEntities = dao.observeAllMarsPhotoItems().getOrAwaitValue()
assertThat(allTestEntities).contains(testEntity)
}
}
@HiltAndroidTest
@HiltAndroidTest와 함께 Hilt를 사용하는 테스트에 애노테이션을 지정해야 한다. @HiltAndroidTest 애노테이션은 각 테스트에 관한 Hilt 구성요소 생성을 담당한다.
@get:Rule - HiltAndroidRule
HiltAndroidRule은 구성요소의 상태를 관리하고, 테스트에서 삽입을 실행하는 데 사용된다. @Before 를 살펴보면, hiltRule.inject() 를 호출하는데, 이 함수로 인해 의존성 주입이 실제로 실행된다. 따라서 인메모리 데이터베이스를 만들지 않고, hiltRule.inject()를 통해 database에 의존성이 주입되게 된다.
@Before
fun setup() {
/*
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MarsPhotoItemDatabase::class.java
).allowMainThreadQueries().build()
*/
hiltRule.inject()
dao = database.marsPhotoDao()
}
추가로 필요한 사항 (LiveDataTestUtil)
LiveDataTestUtil의 필요성과 코드 설명은 앞 글에서 몇 번 다뤘기에 여기서는 코드만 첨부한다.
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS,
afterObserve: () -> Unit = {}
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
try {
afterObserve.invoke()
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
} finally {
this.removeObserver(observer)
}
@Suppress("UNCHECKED_CAST")
return data as T
}
Reference
https://dagger.dev/hilt/testing
https://developer.android.com/training/dependency-injection/hilt-testing
'Android > Testing' 카테고리의 다른 글
[Android/Testing] #10. Fragment Navigation Test (0) | 2022.11.18 |
---|---|
[Android/Testing] #9. Dagger-Hilt 적용하여 프레그먼트 테스트하기 (0) | 2022.11.13 |
[Android/Testing] #7. ViewModel 테스트하기 (with Fake Repository) (0) | 2022.10.22 |
[Android/Testing] #6. 테스트 시작 전에 테스트 더블(Test Double) 이해하기 (0) | 2022.10.08 |
[Android/Testing] #5. 테스트를 위한 기반 프로젝트 생성하기 (0) | 2022.10.02 |