통합 테스트란 앱의 서로다른 파츠를 동시에 테스트하는 것입니다. 이번 영상에서는 뷰모델과 리포지토리를 테스트 하는 통합테스트를 수행해 보도록 하겠습니다.
BookViewModel에는 리포지토리를 통해 DB에 값을 저장하는 saveBook 메소드가 있습니다. 이 메소드를 테스트하는데에 리포지토리의 전체 기능이 필요하진 않기 때문에 리포지토리의 테스트 더블을 만들어서 테스트를 해 보겠습니다. BookViewModel에는 안드로이드 의존성이 없어서 JVM에서 테스트 할 수 있기 때문에 Local Unit Test로 만들어 주겠습니다.
테스트를 시작하기 전에 우선은 BookViewModel의 saveBook이 제대로 수행되었는지 결과값을 확인하기 위한 용도로 favoriteBooks 변수를 추가해 줍니다.
@HiltViewModel
class BookViewModel @Inject constructor(
private val bookSearchRepository: BookSearchRepository,
) : ViewModel() {
...
+ // For test
+ val favoriteBooks: Flow<List<Book>> = bookSearchRepository.getFavoriteBooks()
}
BookViewModel은 생성자로 BookSearchRepository를 필요로 하는데 여기서는 리포지토리의 Fake 테스트 더블을 만들어서 사용하겠습니다. 왜냐하면 뷰모델의 기능을 체크하는데에 리포지토리의 반환값은 뭐가 됐든 상관없기 때문입니다.
우선은 data/repository 패키지에 FakeBookSearchRepository를 만들고 BookSearchRepository 인터페이스를 구현하도록 합니다. Room을 테스트 할 때 메모리 디비를 썼던것처럼 리포지토리도 메모리를 저장공긴으로 사용하도록 할건데요, 여기서는 mutableList 인스턴스를 Room DB 대신 사용하도록 하고 그 동작에 관련된 insertBooks, deleteBooks, getFavoriteBooks만을 구현했습니다.
이로써 테스트에는 사용할 수 있지만 프로덕션에는 사용할 수 없는 테스트 더블이 완성되었습니다.
class FakeBookSearchRepository : BookSearchRepository {
private val bookItems = mutableListOf<Book>()
override suspend fun searchBooks(
query: String,
sort: String,
page: Int,
size: Int
): Response<SearchResponse> {
TODO("Not yet implemented")
}
override suspend fun insertBooks(book: Book) {
bookItems.add(book)
}
override suspend fun deleteBooks(book: Book) {
bookItems.remove(book)
}
override fun getFavoriteBooks(): Flow<List<Book>> {
return flowOf(bookItems)
}
override suspend fun saveSortMode(mode: String) {
TODO("Not yet implemented")
}
override suspend fun getSortMode(): Flow<String> {
TODO("Not yet implemented")
}
override suspend fun saveCacheDeleteMode(mode: Boolean) {
TODO("Not yet implemented")
}
override suspend fun getCacheDeleteMode(): Flow<Boolean> {
TODO("Not yet implemented")
}
override fun getFavoritePagingBooks(): Flow<PagingData<Book>> {
TODO("Not yet implemented")
}
override fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Book>> {
TODO("Not yet implemented")
}
}
다음은 BookViewModel의 테스트를 만들어 줍니다. UI 레이어와 데이터 레이어 두 층에 걸친 동작을 테스트하는 통합 테스트이므로 @MediumTest로 분류해주고요, setUp 안에서 viewModel을 초기화할 때 FakeBookSearchRepository를 전달해 줍니다. saveBook 메소드는 suspend 함수이므로 runTest 코루틴 내부에서 테스트 하기위해 디펜던시를 추가하고, 경고를 없애주기 위해서 @ExperimentalCoroutinesApi 어노테이션도 붙여줍니다.
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2")
}
@ExperimentalCoroutinesApi
@MediumTest
class BookViewModelTest {
private lateinit var viewModel: BookViewModel
@Before
fun setUp() {
viewModel = BookViewModel(FakeBookSearchRepository())
}
@Test
fun save_book_test() = runTest {
val book = Book(
listOf("a"), "b", "c", "d", 0, "e",
0, "f", "g", "h", listOf("i"), "j"
)
viewModel.saveBook(book)
val favoriteBooks = viewModel.favoriteBooks.first()
assertThat(favoriteBooks).contains(book)
}
}
테스트를 실행하면 코드가 테스트를 통과하는 것을 확인할 수 있습니다.
다음은 테스트 클래스에 Hilt로 의존성을 주입하는 법에 대해 알아보도록 하겠습니다.
테스트 더블은 테스트를 작동하게는 하지만, 결국은 동작을 시뮬레이트 할 뿐이므로 구글에서는 사용하지 않을 수 있다면 가능한 한 사용을 줄이라고 권고하고 있습니다. Hilt를 사용하면 의존성의 생성과 주입 및 교체를 보다 간편하게 수행할 수 있게 되므로 테스트 더블의 사용을 줄일 수 있습니다.
여기서는 Instrumented Test를 위해 작성한 BookSearchDaoTest에 Room DB를 주입하면서 Unit Test에 Hilt를 사용하는 법에 대해 알아보도록 하겠습니다.
우선은 다음 디펜던시를 추가합니다.
androidTestImplementation("com.google.dagger:hilt-android-testing:2.41")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.41")
테스트 앱에 Hilt를 적용하기 위해서는 AndroidJUnitRunner를 상속하는 새로운 테스트 런너를 만들어서 사용해야 합니다. androidTest 패키지의 최상단에 HiltTestRunner 클래스를 만들고 newApplication을 오버라이드 할 때 HiltTestApplication의 이름을 넘겨주도록 합니다.
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
그리고 그래들에 설정된 테스트 런너를 다음과 같이 교체해 줍니다.
android {
defaultConfig {
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunner = "com.qualitybitz.booksearchapp.HiltTestRunner"
}
}
다음은 의존성 주입할 Room 모듈을 만들어 주겠습니다. 우선은 di 패키지를 생성하고 TestAppModule 오브젝트를 만들어 줍니다. AppModule과 동일하게 @Module과 @InstallIn을 설정해 주고 provideInMemoryDb 메소드를 생성해 줍니다. 내용은 BookSearchDaoTest의 setUp에서 사용한 것과 동일합니다. BookSearchDatabase 인스턴스는 테스트가 끝나면 사라지는 일회용 객체이기 때문에 @Singleton은 굳이 붙이지 않았습니다. 그리고 프로덕션 코드에도 BookSearchDatabase를 제공해주는 provideBookSearchDatabase가 있기 때문에 Hilt가 어떤 객체를 주입해야 할지 헷갈리게 됩니다. 이것을 방지하기 위해 @Named 어노테이션을 추가하여 Hilt가 객체를 구분할 수 있도록 해 줍니다.
@Module
@InstallIn(SingletonComponent::class)
object TestAppModule {
@Provides
@Named("test_db")
fun provideInMemoryDb(@ApplicationContext context: Context) : BookSearchDatabase =
Room.inMemoryDatabaseBuilder(context, BookSearchDatabase::class.java)
.allowMainThreadQueries()
.build()
}
Hilt를 사용할 준비가 끝났으니 테스트에 의존성을 주입하도록 하겠습니다.
우선은 BookSearchDaoTest의 런너를 교체하여 Hilt의 엔트리포인트로 만들어줍니다.
@ExperimentalCoroutinesApi
-@RunWith(AndroidJUnit4::class)
+@HiltAndroidTest
@SmallTest
class BookSearchDaoTest { }
다음은 의존성으로 주입할 BookSearchDatabase에 @Inject 어노테이션을 붙이고, TestAppModule에서 제공하는 BookSearchDatabase를 주입받도록 @Named 어노테이션도 붙여줍니다.
class BookSearchDaoTest {
- private lateinit var database: BookSearchDatabase
+ @Inject
+ @Named("test_db")
+ lateinit var database: BookSearchDatabase
private lateinit var dao: BookSearchDao
}
그리고나면 Hilt component의 상태와 주입을 관리하는 HiltAndroidRule을 테스트 클래스에 추가해 주고, setUp에서 inject 메소드를 실행하면 주입 설정한 모든 의존성을 Hilt가 한번에 주입해주게 됩니다.
class BookSearchDaoTest {
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
private lateinit var database: BookSearchDatabase
@Inject
@Named("test_db")
lateinit var database: BookSearchDatabase
private lateinit var dao: BookSearchDao
@Before
fun setUp() {
- database = Room.inMemoryDatabaseBuilder(
- ApplicationProvider.getApplicationContext(),
- BookSearchDatabase::class.java
- ).allowMainThreadQueries().build()
+ hiltRule.inject()
dao = database.bookSearchDao()
}
}
이렇게 해서 통합 테스트에 대해 알아보았습니다.