1. 목차
1.1. 소개
1.2. 사전지식
1.3. 배울 것들
1.4. 사용할 것들
1.5. 해볼 것들
1.6. 테스팅 전략 컨셉
1.7. Architecture 와 Testing 의 상관관계
1.8. Fake Data source 만들기
1.9. Test Double 활용하기
1.9.1. step 1. FakeDataSource Class 만들기
1.9.2. step 2. TasksDataSource Interface 구현하기
1.9.3. Step 3. FakeDataSource 에 getTasks 함수 구현하기
1.10. 의존성 주입을 사용해 테스트 작성하기
1.10.1. step 1. 생성자 의존성 주입을 이용하도록 DefaultTasksRepository 수정하기
1.10.2. step 2. FakeDataSource 를 테스트에 활용하기
1.10.3. step 3. DefualtTasksRepository getTasks() 테스트하기
1.10.4. step 4. add runBlockingTest
1.11. Fake Repository 생성하기
1.11.1. step 1. TasksRepository 인터페이스 생성하기
1.11.2. step 2. FakeTaskRepository 를 생성하자!
1.11.3. step 3. FakeTaskRepository 메소드를 실제로 구현해보자
1.11.4. step 4. addTasks 를 테스트하기 위해 addTasks 를 구현해보자
1.12. Fake Repository 로 ViewModel 사용하기
1.12.1. step 1. ViewModelFactory 를 만들어 TasksViewModel 을 사용해보자
1.12.2. step 2. TasksViewModelTest 에서 FakeTestRepository 를 사용해보자
1.12.3. step 3. TaskDetailFragment 와 ViewModel 도 업데이트 해보자!
1.13. Test 에서 Fragment 실행해보기!
1.13.1. step 1. Add Gradle Dependencies
1.13.2. step 2. TaskDetailFragmentTest class 만들기
1.13.3. step 3. 테스트에서 fragment 실행하기!
1.14. ServiceLocator 만들기!
1.14.1. step 1. Service Locator 생성하기
1.14.2. step 2. 어플리케이션에서 ServiceLocator 사용하기
1.14.3. step 3. FakeAndroidTestRepository 를 생성하자
1. 목차
1.1. 소개
두 번째 강의는 Test Double
에 대해 배운다. Android 에서 언제 이것을 쓰고 의존성 주입을 사용해 어떻게 구현하는지에 대해 배운다. 이 과정을 거치면, 다음과 같은 요소들을 배울 수 있다
- Repository unit tests
- Fragments and viewmodel integration tests
- Fragment navigation tests
1.2. 사전지식
- 첫번째 강의에서 소개한 지식
- ViewModel, LiveData and Navigation Component
- Coroutine
- Application Architecture
1.3. 배울 것들
- 테스트 전략 짜기
- Test Double 생성법과 사용법, namely fakes and mocks
- 단위 테스트와 통합 테스트를 위해 의존성 주입을 사용하는 법
- Service Locator Pattern
- Repository 테스트 방법
1.4. 사용할 것들
runBlocking
,runBlockingTest
FragmentScenario
Espresso
Mockito
1.5. 해볼 것들
- 의존성 주입과 test double 을 활용한 repository 테스트
- 의존성 주입과 test double 을 활용한 viewModel 테스트
첫 번째 강의에서 이어지는 강의기 때문에, 이전에 사용한 TODO 앱을 그대로 사용한다.
1.6. 테스팅 전략 컨셉
테스트 전략을 사용할 때, 바라봐야할 세 가지의 관점이 있다.
- Scope → test가 얼마나 많은 범위를 테스트하는가? 테스트는 하나의 메소드를 테스트할 수도 있고, 어플리케이션 전체를 테스트할 수도 있다.
- Speed → test는 얼마나 빨라야 하는가?
- Fidelity → test가 얼마나 현실 작업과 가까운가? 예를 들어 네트워크 요청을 해야하는 test 코드를 작성해야 할 경우, 테스트 코드가 실제로 네트워크 요청을 하는지, 혹은 fake result 만 반환하는지에 대한 내용이다. 만약 실제로 네트워크 요청을 통해 결과를 얻어온다면, 높은 Fidelity를 가지고 있다고 칭한다.
이 세가지 요소는 전부 서로간의 trade - off가 존재한다. 예를 들어, speed
- fidelity
관계를 볼 때, 네트워크 요청을 실제로 할 경우 fidelity
는 높지만 speed
는 떨어지게 된다.
- Unit Test → 단일 클래스에서 동작하는데 집중하는 테스트로, 보통 단일 메서드 테스트를 위해 사용한다. 만약 Unit Test 가 실패할 경우 정확히 어디서 실패한지 바로 알 수 있다. 실제 어플리케이션은 단일 메서드보다 훨씬 많은 작업을 수행하므로, 매우 낮은 Fidelity 를 가지고 있으며, 코드를 변경하더라도 매우 빠르게 작동한다.
- Integration Test → 주로 여러 클래스가 함께 동작할 때 상호작용을 테스트하는 방법이다. 주로 여러 클래스가 하나의 기능을 테스트 하는 방식으로 사용한다.
- End to end test(E2e) → 실제 어플리케이션과 거의 동일하게 테스트하며, 어플리케이션의 매우 큰 범위를 테스트하므로 느리다. 제일 높은 fidelity 를 가지고 있으며, 테스트로 어플리케이션이 실제로 잘 작동할 거라는 것을 알 수 있다. 주로 Instrumented Test 에 속한다 (in the
androidTest
)
1.7. Architecture 와 Testing 의 상관관계
어플리케이션의 아키텍쳐는 테스트와 매우 큰 상관관계가 있다. 예를 들어 극단적으로 나쁜 아키텍쳐로 설계된 어플리케이션의 경우 모든 로직을 한 메소드에 몰아넣었을 경우가 있는데, 이것을 테스트하기 위해서 End to end 테스트만을 사용해야 하는 경우가 있다.
따라서 best Practice는 어플리케이션의 로직을 여러 메소드와 클래스로 나누고, 각 로직을 독립적으로 테스트할 수 있도록 어플리케이션을 설계하는 것이다. 이 예제에서 사용하는 TODO 프로젝트는 다음과 같은 Architecture 로 설계되었다.
1.8. Fake Data source 만들기
Fake Test Double
(실제 객체와 동일한 역할을 수행하지만 구현은 훨씬 간단한) 을 만들어보자. 일단 main soruce set에 있는 DefaultTasksRepository
클래스를 열어보자. 이 클래스를 이용해서 다음 테스트를 진행하려고 한다.
Repository 패턴을 알고 있는 개발자라면, 하나의 Repository 가 로컬과 리모트 데이터 소스에 의존하는 것을 알 것이다. 따라서 Repository 의 데이터를 얻어오는 모든 메서드들은 거의 이 두가지에 의존한다고 할 수 있다.
DefaultTasksRepo
의 다음 함수를 살펴보자
suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>> {
if (forceUpdate) {
try {
updateTasksFromRemoteDataSource()
} catch (ex: Exception) {
return Result.Error(ex)
}
}
return tasksLocalDataSource.getTasks()
}
getTasks
와 같은 함수는 Repository 를 만들기 위한 아주 기본적인 함수다. 이 함수는 suspend 함수로 SQLite 데이터베이스나 네트워크 요청을 통해 읽어온 Task 리스트를 반환한다. 단순히 repository 의 메소드를 이용하기 위해서 매우 많은 클래스들을 의존하고 있다.
Repository 를 테스트하기가 매우 까다로운 이유가 바로 여기에 있다.
- 간단한 Repository 테스트에도 Database 를 생성하고 관리하는 작업을 모두 처리해줘야한다.
- 네트워크 요청이나 데이터베이스 연결같은 과정은 느리고, 항상 성공을 보장하지도 않는다. 따라서 일관적인 테스트를 진행할 수 없다.
- 위와 같은 원인으로 무엇이 테스트를 실패하게 하는지 원인을 파악하기 힘들어진다. repository 코드가 아닌것을 테스트하는데도 네트워크 요청, DB 연결 실패등의 원인으로 테스트 전체가 실패할 수 있다.
1.9. Test Double 활용하기
Test Double은 영화 촬영 시 위험한 역할을 대신하는 스턴트 더블에서 유래한 용어로, 테스트를 위해 실제 객체를 대신하는 객체들을 의미한다. Test Double 에도 여러가지 형태가 있는데, 호종님이 잘 정리해놓으신 글이 있으니 한 번 읽어보고 오자 ㅎㅎ
우리는 이 예제에서 Fake 테스트 더블을 활용할 것이다. DB나 네트워크 연결 없이 단순히 캐시만 가지고 Repository 의 역할을 수행하는 가짜 객체를 만들 것이다.
1.9.1. step 1. FakeDataSource Class 만들기
- test source set 에서, 최하위 패키지에 data 패키지와 source패키지를 생성하자.
- data.source 패키지 안에
FakeDataSource
클래스를 생성하자
1.9.2. step 2. TasksDataSource Interface 구현하기
FakeDataSoruce
를 테스트 더블로 활용하기 위해서, 실제 데이터 소스인TasksLocalDataSource
와TasksRemoteDataSource
를 대체하도록 작성할 필요가 있다.
- 위 두 가지 데이터 소스가
TasksDataSource
인터페이스를 어떻게 구현하는지 보도록 하자.
class TasksLocalDataSource internal constructor(
private val tasksDao: TasksDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }
object TasksRemoteDataSource : TasksDataSource { ... }
- 이제
FakeDataSource
가TasksDataSource
인터페이스를 구현하도록 작성해주자
- 그런다음
TasksDataSoruce
인터페이스의 모든 함수를 구현처리해주자
1.9.3. Step 3. FakeDataSource 에 getTasks 함수 구현하기
FakeDataSource
는 fake 테스트 더블이다. fake 는 실제 객체의 기능을 테스트하기 쉽도록 단순히 구현한 객체를 의미한다. 따라서 fake data source는 실제 데이터 소스와 다르게 네트워크 혹은 db로부터 데이터를 가지고 오지 않고, 메모리에 존재하는 데이터만을 사용한다. 실제 네트워크나 db와 연동하지 않기 때문에 어플리케이션 레벨에선 사용할 수 없는 클래스지만, 테스트만을 위해선 완벽하다고 할 수 있다.
FakeDataSource
DefaultTasksRepository
가 네트워크나 데이터 베이스에 의존하지 않도록 만들어준다.
- 테스트를 위해서 필수적인 기능만 구현해서 제공해준다.
- FakeDataSource의 생성자에 MutbleList<Task>? 로 기본적인 리스트를 삽입해주자.
class FakeDataSource(var tasks : MutableList<Task>? = mutableListOf()) : TaskDataSource
이제 tasks
리스트는 데이터베이스 혹은 네트워크 요청 결과의 fake 객체가 된다.
- getTasks 함수를 작성하자 → If
tasks
isn'tnull
, return aSuccess
result. Iftasks
isnull
, return anError
result.
- deleteAllTasks 함수를 작성하자 → clear the tasks
- saveTask 함수를 작성하자. → add the task to the list
완성된 코드는 다음과 같다.
override suspend fun getTasks(): Result<List<Task>> {
tasks?.let {
return Result.Success(ArrayList(it))
}
return Result.Error(IllegalStateException("task is null"))
}
1.10. 의존성 주입을 사용해 테스트 작성하기
이 단계에서 수동 의존성 주입을 사용해 fake test double 을 사용해야 한다.
우리가 지금 FakeDataSource
를 구현했지만, 아직 어떻게 사용할지 감이 안잡힌다. 지금까지 DefaultTasksRepository
가 TasksDataSource
클래스에 의존하기 때문에 위에서 FakeDataSource
를 구현했다. 그러나 지금 DefaultTasksRepository
를 당장 열어서 코드를 한 번 살펴보자.
DefaultTasksRepository.kt
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
companion object {
@Volatile
private var INSTANCE: DefaultTasksRepository? = null
fun getRepository(app: Application): DefaultTasksRepository {
return INSTANCE ?: synchronized(this) {
DefaultTasksRepository(app).also {
INSTANCE = it
}
}
}
}
init {
val database = Room.databaseBuilder(application.applicationContext,
ToDoDatabase::class.java, "Tasks.db")
.build()
tasksRemoteDataSource = TasksRemoteDataSource
tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
}
위 코드를 살펴보면 무언가 이상하다는 것을 알 수 있다. 우리가 지금 데이터베이스와 네트워크에 의존하지 않는 테스트 더블 객체를 생성하기 위해 FakeDataSource를 구현했는데, 정작 DefaultTasksRepository 는 의존성을 객체 내부에서 할당하고 있어 FakeDataSource를 주입할 수 없다.
따라서 우리는 DataSource를 외부에서 주입받도록 DefaultTasksRepository 를 수정해야 한다. 외부에서 의존성을 주입하는 것을 바로 의존성 주입
이라고 부른다.
의존성 주입에도 많은 형식이 있지만, 일단 지금은 생성자로 의존성을 주입해보자.
1.10.1. step 1. 생성자 의존성 주입을 이용하도록 DefaultTasksRepository 수정하기
DefaultTasksRepository
가 생성자로 DataSource에 대한 의존성을 주입받도록 수정해보자.
DefaultTasksRepository.kt
// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }
// WITH
class DefaultTasksRepository(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
- 의존성을 생성자로 주입했기 때문에,
init
영역을 제거하자. 더 이상 하드코딩으로 의존성을 생성할 필요가 없다!
- 또한 생성자에서 프로퍼티를 선언했기 때문에, 늙다리 프로퍼티들을 삭제해주자.
// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
- 마지막으로 싱글톤 인스턴스를 얻어오는
getRepository
함수를 수정해주자.
companion object {
@Volatile
private var INSTANCE: DefaultTasksRepository? = null
fun getRepository(app: Application): DefaultTasksRepository {
return INSTANCE ?: synchronized(this) {
val database = Room.databaseBuilder(app,
ToDoDatabase::class.java, "Tasks.db")
.build()
DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
INSTANCE = it
}
}
}
}
이제 DefaultTasksRepository 는 외부에서 의존성을 주입받는 아주 올바른 객체가 되었다.
1.10.2. step 2. FakeDataSource 를 테스트에 활용하기
이제 FakeDataSource
를 주입해 repository 를 사용할 수 있다. DefaultTasksRepository
를 테스트하기 위해 다음과 같은 과정을 진행하자.
DefaultTasksRepository
를 우클릭하고, Test 생성하기
- 그냥 ok 만 눌러서 test source set 에 생성하자.
DefaultTasksRepositoryTest
클래스에 fake data source의 값을 나타내는 데이터들을 선언하자.
DefaultTasksRepositoryTest.kt
private val task1 = Task("Title1", "Description1")
private val task2 = Task("Title2", "Description2")
private val task3 = Task("Title3", "Description3")
private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
private val localTasks = listOf(task3).sortedBy { it.id }
private val newTasks = listOf(task3).sortedBy { it.id }
- 이제 DefaultTasksRepositoryTest.kt 파일에서, 위
FakeDataSoruce
를 활용한DefaultTasksRepostirory
를 생성해야한다.
private lateinit var tasksRemoteDataSource: FakeDataSource
private lateinit var tasksLocalDataSource: FakeDataSource
// Class under test
private lateinit var tasksRepository: DefaultTasksRepository
- @Before 어노테이션을 사용하여, DefualtTasksRepository 를 세팅해보자.
- remoteTasks 와 localTasks 리스트를 사용해서 fake data source를 초기화해보자.
@Before
fun setDefaultTasksRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
tasksRepository = DefaultTasksRepository(
tasksRemoteDataSource = tasksRemoteDataSource,
tasksLocalDataSource = tasksLocalDataSource
)
}
1.10.3. step 3. DefualtTasksRepository getTasks() 테스트하기
이제 드디어 DefaultTasksRepository 에 대한 테스트를 실시할 시간이다!
- getTasks 메소드를 위한 테스트 코드를 작성하자. getTasks를 true 와 함께 호출하면 (강제로 네트워크 업데이트를 요청한다는 뜻 ㅎ) remote Data Source로부터 데이터를 반환하는지 테스트해보자! → 위에서 remoteDataSource 의 데이터인 task1, task2 가 반환되어야 정상이다.
@Test
fun getForceRemoteDataSource() {
val result = tasksRepository.getTasks(true) as Result.Success
assertEquals(result.data, remoteTasks)
}
그러나 위와 같이 처리하면 에러가 뜨는 것을 볼 수 있다. getTasks 함수는 suspend 함수로, 코루틴 스코프안에서 호출해야하지만, 지금은 코루틴 없이 호출했기 때문이다. 다음 단계에서 이 오류를 해결해보자.
1.10.4. step 4. add runBlockingTest
이제 suspend 함수를 호출하기 위해 테스트 코드에서 coroutine 을 실행하는 법을 배워보자!
그러기 위해선 일단 test coroutine 관련한 의존성을 먼저 추가해야한다. app 수준의 build.gradle 파일에 다음과 같은 의존성을 추가하자.
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
coroutineVersion은 1.6.0 으로 선언하자!
kotlinx-coroutines-test 는 coroutine 테스트를 위한 라이브러리로, runBlockingTest
라는 suspend 함수 테스트 기능을 제공해준다! 여타 코루틴 빌더들과 마찬가지로 코드블록을 받아서 특별한 coroutine context 에서 코드 블록을 동기적으로 실행한다.runBlockingTest
를 활용해 suspend 함수를 호출해보자! 다음 codelab 시리즈에서 이것에 대해 더 많은 것을 배울 것이다.
@ExperimentalCoroutinesApi
어노테이션을 클래스위에다 추가하자. 이것은 실험적인 api 를 사용한다는 의미로, 언제든지 해당 기능이 사라질 수 있음을 의미한다.
- 이제 테스트 코드에서
runBlockingTest
를 활용해 getTasks 함수를 테스트해보자!
@Test
fun getForceRemoteDataSource() {
runBlockingTest {
val result = tasksRepository.getTasks(true) as Result.Success
assertEquals(result.data, remoteTasks)
}
}
이제 테스트를 시작해서 테스트가 통과하는지 확인해보자!!!!!
최종적인 코드는 다음과 같다!
@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {
private val task1 = Task("Title1", "Description1")
private val task2 = Task("Title2", "Description2")
private val task3 = Task("Title3", "Description3")
private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
private val localTasks = listOf(task3).sortedBy { it.id }
private val newTasks = listOf(task3).sortedBy { it.id }
private lateinit var tasksRemoteDataSource: FakeDataSource
private lateinit var tasksLocalDataSource: FakeDataSource
private lateinit var tasksRepository: DefaultTasksRepository
@Before
fun setDefaultTasksRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
tasksRepository = DefaultTasksRepository(
tasksRemoteDataSource = tasksRemoteDataSource,
tasksLocalDataSource = tasksLocalDataSource
)
}
@Test
fun getForceRemoteDataSource() {
runBlockingTest {
val result = tasksRepository.getTasks(true) as Result.Success
assertEquals(result.data, remoteTasks)
}
}
}
1.11. Fake Repository 생성하기
방금까지의 과정으로 어떻게 repository 를 테스트하는지 알아보았다! 이제 다음으로, 다시 한번 의존성 주입과 다른 테스트 더블을 생성해 Unit 테스트
와 ViewModel integration test
를 시행해보자!
Unit 테스트 (단위 테스트) 는 class 혹은 단일 메소드만 테스트 해야한다. 이것을 testing in isolation 라고 부른다. 따라서 TasksViewModelTest 는 TasksViewModel 코드만 테스트해야 하며, 데이터베이스, 네트워크나 다른 repository에 대한 테스트를 시행해서는 안된다.
이제 이 단계에서 방금 했던 것처럼 fake repository 를 생성해 ViewModel 에 주입하도록 하자.
1.11.1. step 1. TasksRepository 인터페이스 생성하기
생성자 의존성 주입을 위한 첫 단계는 fake 객체와 실제 객체끼리 공유할 인터페이스를 선언하는 것이다.
앞선 과정에서 우리는 TasksRemoteDataSource
와 TasksLocalDataSource
대신 FakeDataSource
를 선언해 repository 에 주입하였다. 이것은 세 객체 모두 TasksDataSource
라는 인터페이스를 구현하기 때문에 가능한 것이다.
class DefaultTasksRepository(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
바로 이런 인터페이스 덕분에 우리는 FakeDataSource를 주입할 수 있었던 것이다!
따라서 fake repository 생성을 위해 DefualtTasksRepository
에 대한 인터페이스를 생성해주자. 인터페이스는 DefaultTasksRepository
의 모든 public 메소드를 포함해야 한다.
DefaultTaskRepository
를 열어 우클릭하자. 그리고 Refactor → Extract → Interface 를 클릭해주자!
- 인터페이스 이름을 TasksRepository 로 변경하자!
- Refactor 를 클릭하자. 추출된
TasksRepository
인터페이스가 DefaultTasksRepository.kt 파일 안에 보일 것이다. TasksRepository 인터페이스를 새로 생성해 코드를 옮기도록 하자!
- 어플리케이션을 실행해 모든 것이 정상적으로 동작하는지 확인하자!
1.11.2. step 2. FakeTaskRepository 를 생성하자!
이제 TasksRepository
인터페이스를 구현하는 FakeTaskRepository
를 생성할 수 있다!
- test source set 의 data.source 패키지안에
FakeTasksRepository
클래스를 생성하자.
TaskRepository
인터페이스를 구현하고, 인터페이스의 모든 메소드들을 Implement하자
1.11.3. step 3. FakeTaskRepository 메소드를 실제로 구현해보자
FakeTaskRepository 는 이제 원래 DefaultTasksRepository 처럼 DataSource 같은 거추장스러운 것들이 필요없다. 이제 그냥 테스트의 요구에 맞춰 가짜 출력만 반환하도록 메소드를 구현하면 된다! 이제 LinkedHashMap
이라는 것을 사용해 task 의 리스트를 저장하고, MutableLiveData 를 사용해보자.
- FakeTasksRepository에 Result 타입의
MutableLiveData
와 String, Task 타입의LinkedHashMap
을 선언하자.
class FakeTasksRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private val observableTasks = MutableLiveData<Result<List<Task>>>()
- 다음과 같이 함수들을 구현하자
getTasks
—This method should take thetasksServiceData
and turn it into a list usingtasksServiceData.values.toList()
and then return that as aSuccess
result.
refreshTasks
—Updates the value ofobservableTasks
to be what is returned bygetTasks()
.
observeTasks
—Creates a coroutine usingrunBlocking
and runrefreshTasks
, then returnsobservableTasks
.
override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
return Result.Success(tasksServiceData.values.toList())
}
override suspend fun refreshTasks() {
observableTasks.value = getTasks()
}
override fun observeTasks(): LiveData<Result<List<Task>>> {
runBlocking {
refreshTasks()
}
return observeTasks()
}
1.11.4. step 4. addTasks 를 테스트하기 위해 addTasks 를 구현해보자
- 여러 개의 Task 인자를 받는
addTasks
메소드를 작성하자. 이것을LinkedHashMap
에 추가하고,refreshTask
를 호출하자.addTasks
메소드는 헬퍼 메소드로, 테스트를 더 쉽게 하기 위해 미리 task 를 추가하는 역할을 한다.
FakeTasksRepository.kt
fun addTasks(vararg tasks: Task) {
for (task in tasks) {
tasksServiceData[task.id] = task
}
runBlocking { refreshTasks() }
}
1.12. Fake Repository 로 ViewModel 사용하기
이번 단계에선 ViewModel 에서 Fake Repository 를 사용해 볼 것이다. 생성자 의존성 주입을 사용해서, TasksRepository 를 주입해볼 것이다.
1.12.1. step 1. ViewModelFactory 를 만들어 TasksViewModel 을 사용해보자
일단 Tasks 와 관련된 클래스들부터 업데이트 해보자.
TasksViewModel
을 열자
TasksRepository
를 클래스 내부에서 생성하지 않고, 외부에서 생성자로 주입받도록 코드를 수정해보자
TasksViewModel.kt
// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// Rest of class
}
// WITH
class TasksViewModel(private val tasksRepository: TasksRepository) : ViewModel() {
이제 생성자를 변경했기 때문에, TasksViewModel 을 생성하기 위해서 factory 를 사용해야 한다.
- TasksViewModel.kt 파일 맨 아래쪽 클래스 바깥 쪽에, TasksViewModelFactory 클래스를 생성하자.
TasksViewModel.kt
@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>) =
(TasksViewModel(tasksRepository) as T)
}
ViewModel 을 생성하기 위해 가장 기본적인 ViewModelFactory 사용 방법이다. 이제 TasksFragment
를 업데이트 해보자!
TasksFragment.kt
// REPLACE
private val viewModel by viewModels<TasksViewModel>()
// WITH
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
- 여기까지 따라왔다면 코드가 제대로 동작하는지 확인해보도록 하자!
1.12.2. step 2. TasksViewModelTest 에서 FakeTestRepository 를 사용해보자
이제 viewModel 테스트에서 실제 repository 대신 fake repository 를 사용해보자!
- 우리가 일전에 작성한
TasksViewModelTest
파일을 열어보자. test source set 의 tasks 패키지 안에 생성했었다.
TasksViewModelTest
클래스 안에FakeTestRepository
프로퍼티를 생성하자.
TasksViewModelTest.kt
@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {
// Use a fake repository to be injected into the viewmodel
private lateinit var tasksRepository: FakeTestRepository
// Rest of class
}
- 이제 우리가 이전에 ViewModel 생성을 위해 작성한 @Before 함수에
FakeTestRepository
생성 코드를 작성하고,ViewModelFactory
를 사용해tasksViewModel
을 생성해보자!
@Before
fun setupViewModel() {
fakeRepository = FakeTasksRepository()
// We initialise the tasks to 3, with one active and two completed
val task1 = Task("Title1", "Description1")
val task2 = Task("Title2", "Description2", true)
val task3 = Task("Title3", "Description3", true)
fakeRepository.addTasks(task1, task2, task3)
tasksViewModel = TasksViewModel(fakeRepository)
}
- 우리가
TasksViewModel
을 AndroidViewMdoel 에서 그냥 ViewModel 을 상속하도록 변경했기 때문에, 이제 AndroidX Test 요소인@RunWith(AndroidJUnit4::class)
어노테이션을 제거해도 된다!
- 테스트를 실행하고, 정상적으로 통과하는지 확인해보자!
1.12.3. step 3. TaskDetailFragment 와 ViewModel 도 업데이트 해보자!
TaskDetailViewModel
도 DefaultTasksRepository
를 사용한다. 이 뷰모델또한 외부에서 의존성을 주입하도록 수정하자!
TaskDetailViewModel.kt
// REPLACE
class TaskDetailViewModel(application: Application) : AndroidViewModel(application) {
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// Rest of class
}
// WITH
class TaskDetailViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() { // Rest of class }
또한 아까와 같이, TaskDetailViewModelFactory
를 만들어 뷰모델 인스턴스를 여기서 생성하도록 코드를 작성해보자.
TaskDetailViewModel.kt
@Suppress("UNCHECKED_CAST")
class TaskDetailViewModelFactory (
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>) =
(TaskDetailViewModel(tasksRepository) as T)
}
TaskDetailFragment.kt
// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()
// WITH
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
이제 코드가 정상적으로 실행되는지 확인해보자!
이제 ViewModel 을 테스트 하기 위해 FakeTaskRepository 를 사용할 수 있게 되었다!
1.13. Test 에서 Fragment 실행해보기!
우리는 지금까지 Android Framework 가 필요하지 않은 test source set 에서 테스트 만을 작성해왔다. 이제 fragment 와 view-model 사이의 상호작용을 테스트해보기 위해서, 즉 ViewModel 이 UI 를 적절히 업데이트하는지 테스트하기 위해서, integration test 를 작성해볼 단계이다! 이것을 위해서 우리는 다음 두 가지를 배우게 될 것이다.
- ServiceLocator Pattern
- Espresso, Mockito libraries
1.13.1. step 1. Add Gradle Dependencies
첫 번째로 app 수준의 gradle.build 파일에 다음과 같이 의존성을 추가하자.
app/build.gradle
// Dependencies for Android instrumented unit tests
androidTestImplementation "junit:junit:$junitVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Testing code should not be included in the main code.
// Once https://issuetracker.google.com/128612536 is fixed this can be fixed.
implementation "androidx.fragment:fragment-testing:$fragmentVersion"
implementation "androidx.test:core:$androidXTestCoreVersion"
Android Framework 요소인 Fragment 에 대해서 테스트를 실시할 예정이기 때문에, androidTestImplementation
로 의존성을 추가한 것을 확인할 수 있다.
1.13.2. step 2. TaskDetailFragmentTest class 만들기
우리가 테스트할 클래스인 TaskDetailFragment
는 본래 다음과 같이 하나의 작업에 대한 상세정보를 보여준다.
TaskDetailFragment
가 다른 Fragment 들과 비교해 비교적 간단한 기능을 가지고 있기 때문에, 이제부터 이것을 테스트 해보도록 하자.
- taskdetail 패키지를 열어 TaskDetailFragment 에 대해 Test 를 생성하도록 하자.
- 꼭꼭
androidTest source set
에다가 테스트를 생성하자!
androidTest source set
에다가 쳐넣나요?
그건 알아서 생각해보세요 - 다음과 같은 어노테이션을
TasksDetailFragmentTest
클래스에다가 추가해보자!
TaskDetailFragmentTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
}
위 어노테이션의 역할은 다음과 같다.
@MediumTest
→ Integration Test 라는 것을 의미함 (@SmallTest → Unit test, @LargeTest → end-to-end test 를 의미함)
@RunWith(AndroidJUnit4::class)
→ Android 프레임워크를 사용하는 test runner 를 사용하겠다는 뜻 ㅎ
1.13.3. step 3. 테스트에서 fragment 실행하기!
이제 드디어 AndroidX Testing 라이브러리를 사용해 TaskDetailFragment
를 실행할 단계다! FragmentScenario
는 테스트를 위해 Fragment 의 라이프사이클 대로 제어할 수 있게 해주는 클래스로, AndroidX Test 라이브러리에 포함되있다. 따라서 Fragment 를 테스트 하기 위해 이것을 사용할 것이다.
- 아래 코드를
TasksDetailFragmentTest.kt
파일로 복붙하자
TaskDetailFragmentTest.kt
@Test
fun activeTaskDetails_DisplayedInUi() {
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
위 코드는 다음과 같은 역할을 한다.
- Task 를 생성한다.
- Fragment 에 주입할
task
를 위해,Bundle
을 생성한다.
launchFragmentInContainer
함수는 Theme, Bundle 과 함께FragmentScenario
를 생성한다!
FragmentScenario
를 통해 Fragment 가 생성된 경우엔 테스트를 위해 Activity 와 완전히 분리된 상태로 생성되기 때문에, 적절한 theme 를 무조건 인자로 넘겨줘야한다!
아직 아무것도 검증하지 않았기 때문에, 테스트라고 할 수 없다. 그러나 일단 다음 과정으로 정상적으로 코드가 작동하는지 확인해보도록 하자.
- 일단
Instrumented test
이기 때문에, 안드로이드 장치나 에뮬레이터를 연결하도록 하자.
- 실행해보자
그러면 다음과 같은 상황이 발생할 것이다.
- 일단 Instrumented test 이기 때문에, 에뮬레이터 혹은 실제 안드로이드 장치에서 동작한다.
- Fragment가 실행된다.
- Fragment 가 엄청 빨리 사라진다.... → 막을려면 Thread.sleep(2000) 을 추가해보자
Thread.sleep으로 테스트를 지연시켰다면, 다음과 같은 화면을 볼 수 있다.
우리가 Bundle
로 하나의 Task 데이터를 넘겼지만, 테스트를 위해 실행한 Fragment 는 성공적으로 받지 못한 모양이다! 어째서 불러오지 못한 것일까? 일단 Task.id 는 bundle 로 넘겼지만, 실제 Fragment 가 데이터를 관리할 ViewModel 에서는 Repository 에서 데이터를 받는데, Repository 엔 저장하지 않아서 그렇다.
그러나 우리는 아까 FakeTaskRepository 를 생성했었다.ㅎㅎ 이것을 사용해서 해결해보자
1.14. ServiceLocator 만들기!
TaskDetailFragment
코드를 살펴보면, 다음과 같은 코드로 Fragment 에서 ViewModel에 Repository 의존성 주입을 하고 있는 것을 확인할 수 있다.
TaskDetailFragment
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
우리가 ViewModel 만 테스트할 때는 ViewModel 생성자에 직접 Fake Repository
를 주입해줌으로써 테스트를 용이하게 할 수 있었다. 그러나 Fragment 를 테스트하려니까 문제가 생기는데, Fragment 에서 ViewModel 에 직접 Repository 를 주입한다는 것이다. 우리는 Fake Repository 사용을 원하는데, 알다시피 Fragment 와 같은 컴포넌트들은 프레임워크에 의해 인스턴스화 되기 때문에 의존성 주입을 할 수 있는 방법이 없다. 그렇기 때문에 Service Locator
같은 패턴을 사용해서 의존성 주입을 해야 하는데, 지금부터 그것에 대해 알아보자!
Service Locator
패턴을 사용하면 Fragment 와 ViewModel 의 integration test 를 진행할 수 있다. Service Locator 패턴은 의존성 주입을 대체할 수 있다. Service Locator
라고 부르는 싱글톤 클래스를 생성해서, test code 와 일반 어플리케이션을 위한 의존성을 제공해주는 역할을 한다.
이 예제에선 다음과 같은 과정을 진행할 것이다.
- Repository 를 생성하고 저장할 수 있는
Service Locator
클래스를 생성하자. 기본적으로 일반적인 repository 를 생성할 것이다.
- Repository 가 필요할 땐
Service Locator
를 사용해서 가져올 수 있도록 코드를 리팩토링하자
- 테스트 클래스에서 테스트 더블 repository 를 불러올 수 있도록
Service Locator
함수를 호출하자.
1.14.1. step 1. Service Locator 생성하기
먼저 Service Locator
클래스를 생성하자. 실제로 사용될거라서 main source set 에다가 작성할 것이다.
참고 : Service Locator
는 싱글톤 클래스이므로, object
키워드를 사용할 것이다.
- main source set 에다가
Service Locator
를 생성하자
- ServiceLocator object 를 생성하자.
- database 와 repository 프로퍼티를 생성하고 둘 다 null 로 지정하자
- repository 에
@Volatile
어노테이션을 추가하자.
코드는 다음과 같다.
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
}
지금 당장 ServiceLocator 는 TasksRepository 를 반환하는 역할만 수행하면된다. 이미 메모리에 적재되어있는 DefaultTasksRepository 를 반환하거나, 새로운 DefaultTasksRepository 를 생성해 반환하도록 작성해보자.
다음과 같은 함수를 직접 작성해보자!
provideTasksRepository
—Either provides an already existing repository or creates a new one. This method should besynchronized
onthis
to avoid, in situations with multiple threads running, ever accidentally creating two repository instances.
createTasksRepository
—Code for creating a new repository. Will callcreateTaskLocalDataSource
and create a newTasksRemoteDataSource
.
createTaskLocalDataSource
—Code for creating a new local data source. Will callcreateDataBase
.
createDataBase
—Code for creating a new database.
최종적인 코드는 다음과 같다.
ServiceLocator.kt
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
fun provideTasksRepository(context: Context): TasksRepository {
synchronized(this) {
return tasksRepository ?: createTasksRepository(context)
}
}
private fun createTasksRepository(context: Context): TasksRepository {
val newRepo =
DefaultTasksRepository(TasksRemoteDataSource, createTasksLocalDataSource(context))
tasksRepository = newRepo
return newRepo
}
private fun createTasksLocalDataSource(context: Context): TasksDataSource {
val database = database ?: createDataBase(context)
return TasksLocalDataSource(database.taskDao())
}
private fun createDataBase(context: Context): ToDoDatabase {
val result = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java, "Tasks.db"
).build()
database = result
return result
}
}
1.14.2. step 2. 어플리케이션에서 ServiceLocator 사용하기
이제 ServiceLocator 에서 repository 를 생성하도록 어플리케이션 코드를 수정해야 한다. repository 는 기본적으로 단일 인스턴스를 취해야 하기 때문에, TodoApplication calss 에서 인스턴스를 제공하도록 하자.
- 최상위 패키지에 있는
TodoApplication
클래스를 열고 repository 프로퍼티를 생성하자. 그리고ServiceLocator.provideTaskRepository
를 사용해서 인스턴스를 주입하자.
TodoApplication.kt
class TodoApplication : Application() {
val taskRepository: TasksRepository
get() = ServiceLocator.provideTasksRepository(this)
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(DebugTree())
}
}
이제 repository 를 어플리케이션 단에 생성했기 때문에, DefaultTasksRepository
에 있는 getRepository
메소드는 제거할 수 있다.
DefaultTasksRepository
를 열어 companion object 를 제거하자
DefaultTasksRepository.kt
// DELETE THIS COMPANION OBJECT
companion object {
@Volatile
private var INSTANCE: DefaultTasksRepository? = null
fun getRepository(app: Application): DefaultTasksRepository {
return INSTANCE ?: synchronized(this) {
val database = Room.databaseBuilder(app,
ToDoDatabase::class.java, "Tasks.db")
.build()
DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
INSTANCE = it
}
}
}
}
이제 getRepository 를 사용하는 모든 곳을 application 클래스에 존재하는 taskRepository 로 대체하자. 이제 어플리케이션에서 쓰는 모든 repository 는 Service Locator 가 제공하는 것이라고 보장할 수 있다.
TaskDetailFragment
를 열어getRepository
호출부를 찾아보자
TodoApplication
에서 repositry 를 가져오도록 코드를 수정해보자
TaskDetailFragment.kt
// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
// WITH this code
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
TasksFragment
도 동일하게 작업해보자
TasksFragment.kt
// REPLACE this code
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
// WITH this code
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
StatisticsViewModel
과AddEditTaskViewModel
도 똑같이 작업하자
// REPLACE this code
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// WITH this code
private val tasksRepository = (application as TodoApplication).taskRepository
- 이제 어플리케이션을 실행해 모든것이 정상적으로 작동하는지 아라보자
1.14.3. step 3. FakeAndroidTestRepository 를 생성하자
이전에 이미 FakeTaskRepository 를 생성했었다. 그러나 이것은 test source set 에서 생성했기 때문에 androidTest source set 과 공유할 수 없다. 따라서 androidTest source set 에 복붙해야한다.
test source set 에 존재하는 FakeTaskRepository 를 복사해서 androidTest source set 에다가 붙여넣어보자.
'안드로이드 > Android' 카테고리의 다른 글
TDD - 안드로이드 예제와 함께 TDD 를 배워보자! (0) | 2021.12.27 |
---|
Comment