Advanced Android in Kotlin 05.2: Introduction to Test Doubles and Dependency Injection | Android Developers
In this codelab you’ll learn to set up manual dependency injection, a service locator, and how to use fakes and mocks in your Android Kotlin apps. In doing so, you’ll learn how to test a repository and write fragment integration tests.
developer.android.com
안드로이드 독학하기!
테스트 Codelab을 이어서 진행하고 있습니다.😆
이전에는 viewModel에 제한된 간단한 기능만 테스트 해보았다면 이번에는 test Double를 이용하는 방법으로 테스트하는 방법을 학습해보고자 합니다.🤗
4. Fake Data Source 만들기
이전에 알아봤듯이 Unit test는 하나의 클래스 또는 그 클래스 내의 하나의 메서드만을 테스트하는 것이 목표입니다.
하지만, 실제 프로젝트 코드에서 하나의 특정 클래스만을 테스트하는 것은 쉽지 않습니다.
아래 코드에서 처럼
class DefaultTasksRepository private constructor(application: Application) {
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
/* code */
}
DefaultTasksRepository 클래스 하나를 테스트하려고 해도 tasksRemoteDataSource와 tasksLocalDataSource와 같은 다른 클래스들이 필요한 것을 알 수 있습니다.
🌹 이를 DefaultTasksRepository는 TasksDataSource에 의존(Depend)하고 있다고 하며, tasksRemoteDataSource와 tasksLocalDataSource는 DefaultTasksRepository의 dependencies라고 합니다.
DefaultTasksRepository 코드를 보아도 데이터를 저장하거나 네트워크 통신을 하기 위해 DataSource 클래스의 메서드들을 호출하는 것을 알 수 있습니다.

4.1 Repository를 테스트하기 힘든 이유
위와 같이 DataSource 클래스에 의존하고 있는 Repository를 테스트하기 힘든 이유가 있습니다.
4.1.1 데이터베이스 테스트?
첫 번째는 Repository의 메서드 코드 자체는 간단하지만, 그 메서드 내부에서 데이터베이스에 접근할 수 있다는 것입니다.
데이터베이스를 테스트하기 위해서 로컬 환경에서 테스트할지 실제 기기 환경에서 테스트할지 결정해야 하며, 로컬에서 테스트할 시에는 Simulated Android Environment를 구성하기 위해 Testing Basics 포스트에서 보았듯이 AndroidX를 사용해야 합니다.
4.1.2 불안정한 테스트
만약 네트워크와 관련된 코드를 테스트할 때에는 일단 시간이 오래 걸릴 뿐만 아니라, 코드 자체의 에러가 아닌 네트워크 상태에 따라 테스트가 실패할 수도 있습니다.
🌹 불안전한 테스트(Flaky test)는 동일한 코드에 대해서 어떨때는 테스트를 통과하는데, 또 어떨때는 테스트에 실패하는 테스트를 의미합니다.😱
4.1.3 문제가 발생하는 지점을 찾기 어렵다.
Repository가 아닌 부분의 코드도 함께 테스팅되기 때문에 테스트가 실패하였을 경우 어는 부분에서 에러가 발생하였는지 판단하기 쉽지 않습니다.
예를 들어, 데이터베이스 코드와 같이 의존하고 있는 코드에서 에러가 발생해도 Repository Unit test 자체가 실패한 것으로 보이기 때문입니다.
4.2 Test Double
이렇게 테스트하기 어려운 Repository를 보다 쉽게 테스트하기 위해서 사용할 수 있는 것이 바로 Test Double 입니다.
Test Double은 테스트 내에서 실제 클레스를 대체해 사용하기 위헤 특별히 만들어진 클래스입니다.
👇🏻 Test Double Type
Testing on the Toilet: Know Your Test Doubles
By Andrew Trenk This article was adapted from a Google Testing on the Toilet (TotT) episode. You can download a printer-friendly version ...
testing.googleblog.com
여러 Test Double 타입 중 안드로이드에서 주로 사용되는 것은 Fakes와 Mocks입니다.🤗
4.3 FakeDataSource를 만들어보자
이곳에서는 FakeDataSource를 만들고
DefaultTasksRepository가 실제 data source 대신에 FakeDataSource를 사용하여 Unit test를 어떻게 작성하는지 알아보도록 하겠습니다.
4.3.1 FakeDataSource 클래스 만들기
해당 클래스는 실제 코드의 LocalDataSource와 RemoteDataSource의 Test Double 역할을 수행합니다.
- test 디렉토리 하위에 data/source package를 만들어줍니다.
- 해당 package 내에 FakeDataSource 클래스를 만들어줍니다.
👇🏻 요렇게

4.3.2 TasksDataSource Interface 구현하기
FakeDataSource가 실제 Data Source(TasksLocalDataSource, TasksRemoteDataSource)를 대신해 Test Double로서 사용될 수 있으려면 동일한 기능들을 제공해주어야 합니다.
🌹 즉! 동일한 Interface를 구현해야 한다는 것을 의미합니다.
class FakeDataSource: TasksDataSource{
// 구현할 메서드들
}
4.3.3 getTasks 메서드 구현하기
FakeDataSource는 Fake 타입의 Test Double입니다!
Fake는 동작하는 클래스를 제공해주는 Test Double이며, 이 때 제공해주는 기능은 테스트에는 적합하지만 실제 제품에는 사용할 수 없는 기능임에 유의해야 합니다.
🌹 동작(Working)하는 클래스(구현)은 주어진 input에 대해 실제와 같은 output을 생산해주는 클래스를 의미합니다.
예를 들어, FakeDataSource는 데이터를 정상적으로 저장하고 저장된 데이터를 가져오는지 확인해보기 위해 실제 네트워크나 데이터베이스 대신 in-memory 리스트를 사용합니다.
단, 앞에서도 말했듯이 이는 그저 테스트를 위한 클래스로 실제 서버 또는 데이터베이스를 사용하는 것이 아니기 때문에 절대 실제 제품(production)에서는 사용하면 안 됩니다.
그럼 먼저, FakeDataSource에서 사용할 in-memory 리스트를 추가해주도록 하겠습니다.
class FakeDataSource(
var tasks: MutableList<Task>? = mutableListOf()
) : TasksDataSource { }
이 tasks 리스트가 데이터베이스나 서버에 저장되어야 할 tasks 정보를 대신하여 테스트에서 사용할 task 데이터입니다.
그럼 getTasks 메서드를 이 tasks 리스트를 사용하여 구현해보도록 하겠습니다.
override suspend fun getTasks(): Result<List<Task>> {
tasks?.let { return Result.Success(ArrayList(it)) }
return Result.Error(Exception("Tasks not found"))
}
이와 동일하게 deleteAllTasks와 saveTask 메서드도 구현해보면 아래와 같습니다.
override suspend fun deleteAllTasks() {
tasks?.clear()
}
override suspend fun saveTask(task: Task) {
tasks?.add(task)
}
5. DI를 사용하여 테스트 작성하기
이제 앞에서 열심히 만든 FakeDataSource를 실제로 테스트하고자 하는 DefaultTasksRepository에서 사용할 수 있도록 해주면 됩니다.
어떻게 해줄 수 있을까요?🤨🤨
현재 DefaultTasksRepository 클래스 코드에서는 init 메서드 내에서 dependencies(의존성들)를 생성해주고 있습니다.
init {
val database = Room.databaseBuilder(application.applicationContext,
ToDoDatabase::class.java, "Tasks.db")
.build()
// 객체를 직접 생성
tasksRemoteDataSource = TasksRemoteDataSource
tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
}
의존성 주입에 대해 학습해보았다면 알 수 있듯이, 이렇게 의존성 객체를 클래스 코드 내에서 직접 생성할 경우 dependencies(의존성들)를 다른 클래스(여기서는 Test Double)로 교체할 수 없습니다. 😭
이를 해결하기 위해 의존성들을 외부에서 제공해주는 방법이 있습니다. 이를 Dependency Injection(의존성 주입)이라고 합니다.

5.1 DefaultTasksRepository에 DI 적용하기
Constructor Dependency Injection(생성자 의존성 주입)을 통해 Test Double 객체를 생성자 파라미터로 전달함으로서 쉽게 기존 의존성을 대체할 수 있습니다.
🌹 이 코드랩에서는 간단하게 수동 의존성 주입만을 사용합니다.🤗
5.1.1 생성자에 의존성 추가하기
class DefaultTasksRepository constructor(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) { }
모든 의존성이 생성자에 정의되어 있기 때문에 기존에 있던 멤버 변수들과 init 메서드 내에 초기화 문을 제거해줍니다.
5.1.2 getRepository 코드 수정하기
Companion Object 내의 getRepository 코드를 아래와 같이 수정해줍니다.
// singleton
fun getRepository(app: Application): DefaultTasksRepository {
return INSTANCE ?: synchronized(this) {
val database = Room.databaseBuilder(
app,
ToDoDatabase::class.java, "Tasks.db"
).build()
DefaultTasksRepository(
tasksRemoteDataSource = TasksRemoteDataSource,
tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
).also { INSTANCE = it }
}
}
5.2 FakeDataSource를 사용하기
이제 DI를 통해 DefaultTasksRepository에서 FakeDataSource를 사용할 수 있게 되었습니다.
이번에는 이 둘을 이용해 실제 테스트 코드를 작성해보도록 하겠습니다.🤗
5.2.1 DefaultTasksRepositoryTest 클래스 만들기

5.2.2 테스트에 사용할 멤버 변수들을 추가해줍니다.
이 때 필요한 멤버 변수는
- 실제 데이터베이스와 서버에 저장되어 있는 tasks 데이터들을 대신할 리스트 3개
- FakeDataSource 2개(remote 1개, local 1개)
- 실제 테스트 대상인 DefaultTasksRepository 타입 변수 1개
internal class DefaultTasksRepositoryTest {
private val tasks1 = Task("Title1", "Description1")
private val tasks2 = Task("Title2", "Description2")
private val tasks3 = Task("Title3", "Description3")
private val remoteTasks = listOf(tasks1, tasks2).sortedBy { it.id }
private val localTasks = listOf(tasks3).sortedBy { it.id }
private val newTasks = listOf(tasks3).sortedBy { it.id }
private lateinit var tasksRemoteDataSource: FakeDataSource
private lateinit var tasksLocalDataSource: FakeDataSource
// 실제 테스트 대상
private lateinit var tasksRepository: DefaultTasksRepository
}
5.2.3 초기 Setting 해주기
테스트할 DefaultTasksRepository를 생성해줍니다.
이 때 의존성 주입할 DataSource들은 FakeDataSource 타입의 Test Double임을 잊지 말아야 합니다.👍🏻
@Before
fun createRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
tasksRepository = DefaultTasksRepository(
tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Main
)
}
5.3 DefaultTasksRepository의 getTasks 메서드 테스트 코드 작성하기
드디어 첫 목표였던 getTasks 메서드 테스트 코드를 작성할 차례입니다!😆
테스트 코드 자체는 그리 복잡하지 않습니다.
@Test
fun getTasks_requestAllTasksFromRemoteDataSource() {
// 1. When
val tasks = tasksRepository.getTasks(true) as Result.Success
// 2. Then
assertThat(tasks.data, IsEqual(remoteTasks))
}
이렇게 코드를 작성하면!

요런 에러가 발생하게 됩니다. 😭
5.4 runBlockingTest 추가하기
getTasks가 suspend 함수이기 때문에 코루틴 또는 다른 suspend 함수 내에서만 호출 가능하다는 에러문을 확인할 수 있습니다.
즉, 테스트를 위해 coroutine scope가 필요합니다.🔥
테스트 코드에서 coroutine을 다루기 위해서는 별도의 dependency를 추가해주어야 합니다.
// 저는 1.6.4 버전을 사용하였습니다.
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
이번 테스트에서는 runBlockingTest 라는 함수를 사용할 것입니다.
이 함수는 coroutines test library에서 제공되며, 특별한 코루틴 Context에서 전달된 코드 블록을 동기적으로 실행시켜줍니다.
🔥 이 runBlockingTest에 대해서는 다음에 학습할 Codelab인 Testing Coroutines and Jetpack integrations에서 더 알아보도록 하겠습니다.
runBlocking을 사용하기 위해서는
5.4.1 클래스에 @ExperimentalCoroutinesApi 어노테이션을 추가
- 해당 클래스 내에서 experimental coroutine api(runBlockingTest)를 사용할 것임을 알려주는 어노테이션입니다.
@ExperimentalCoroutinesApi
internal class DefaultTasksRepositoryTest {}
5.4.2 테스트 함수에 runBlockingTest 추가하기
@Test
fun getTasks_requestAllTasksFromRemoteDataSource() = runBlockingTest {
// 1. When
val tasks = tasksRepository.getTasks(true) as Result.Success
// 2. Then
assertThat(tasks.data, IsEqual(remoteTasks))
}
getTasks 메서드가 호출된 위치가 runBlocking을 통해 생성된 coroutine Scope 내부이기 때문에 에러가 사라진 것을 알 수 있습니다.
이렇게 완성된 테스트를 실행해보면

테스트가 성공적으로 완료되었습니다. 🤗👍🏻
5.5 Deprecated 되어버린 runBlockingTest
runBlockingTest가 1.8.0부터는 아에 제거되어버렸다고 하네요.😭 runBlockingTest 대신에 runTest를 사용할 수 있다고 합니다.
@Test
fun getTasks_requestAllTasksFromRemoteDataSource() = runTest {
// 1. When
val tasks = tasksRepository.getTasks(true) as Result.Success
// 2. Then
assertThat(tasks.data, IsEqual(remoteTasks))
}
지금까지 repository를 테스트하기 위해 Data Soure Test Double을 만들고 생성자를 통해 의존성을 주입하는 방법에 대해서 알아보았습니다.
다음으로는 이 repository에 의존성을 가지고 있는 ViewModel의 테스트 코드를 작성하는 방법에 대해서 알아보도록 하겠습니다!
'Android > Android' 카테고리의 다른 글
| [Android:Codelab] Dependency Injection and Test Doubles - 4 (0) | 2023.01.25 |
|---|---|
| [Android:Codelab] Dependency Injection and Test Doubles - 3 (0) | 2023.01.25 |
| [Android:Codelab] Dependency Injection and Test Doubles - 1 (0) | 2023.01.24 |
| [Android:Codelab] Testing Basic-4 (0) | 2023.01.24 |
| [Android:Codelab] Testing Basic-3 (0) | 2023.01.21 |