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을 이어서 진행하고 있습니다.😆
8. Test 디렉토리에 Fragment가!
들어가기에 앞서 이번에 해볼 Integration test란?
여러 클래스가 함께 사용되었을 때 정상적으로 동작하는지 테스트해보는 것을 말합니다.
(여럿이라는 점에서 Unit test와 다릅니다.)
Integration test는 local(test 디렉토리)에서 실행될 수도 있고 실제 기기(androidTest 디렉토리)에서 실행될 수도 있습니다.
8.1 필요한 dependency 추가하기
andoridTest에서 사용할 것이기 때문에 androidTestImplementation을 이용해 dependency를 추가해야 합니다.
androidTestImplementation "junit:junit:$junitVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
debugImplementation "androidx.fragment:fragment-testing:$fragmentVersion"
implementation "androidx.test:core:$androidXTestCoreVersion"
🤔 fragment-testing은 debugImplementation로 추가하지 않으면 빨간줄이 그어집니다.
- junit:junit - Unit test를 위한 도구
- kotlinx-coroutines-test - coroutine 테스트 라이브러리
- fragment-testing - test에서 fragment를 생성하고 변경할 수 있도록 해주는 AndroidX 테스트 라이브러리
- androidx.test:core - AndroidX Test 라이브러리 코어
8.2 Fragment Test를 위한 클래스 만들기
모든 Fragment 중 가장 간단한 기능을 제공하는 TaskDetailFragment에 대한 테스트 코드를 작성해보도록 하겠습니다.
👇🏻 TaskDetailFragment 화면 이미지
아래와 같이 테스트 클래스를 만들어줍니다.
🌹 이때 test가 아닌 androidTest source set인 점에 유의하세요.
시각적인 것을 테스트할 때는 instrumented test로 생각해서 androidTest source set을 사용합니다.
@MediumTest
@RunWith(AndroidJUnit4::class)
internal class TaskDetailFragmentTest {}
@MediumTest
- 이 테스트가 integration test라는 것을 표시합니다.
(Unit test는 @SmallTest, end-to-end test는 @LargeTest 입니다.)
@RunWith(AndroidJUnit4::class)
- AndroidX Test 라이브러리를 사용할 때 필요한 어노테이션입니다.
8.3 테스트에서 fragment 사용하기
코드를 먼저 확인해보도록 하겠습니다.
@Test
fun activeTaskDetails_DisplayedInUi() {
// 1. Given: Add active (incomplete) task to the DB
val activeTask = Task("ActiveTask", "AndroidX Rocks", false)
// 2. When: TaskDetailFragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
8.3.1 Bundle 생성
TaskDetailFragment에 필요한 taskId를 전달할 수 있도록 Bundle을 생성해줍니다.
8.3.2 launchFragmentInContainer
- bundle과 theme를 프로퍼티로 전달하여 launchFragmentInContainer를 호출해 FragmentScenario를 반환 받습니다.
- FragmentScenario는 AndroidX Test 라이브러리가 지원해주는 클래스로 테스트에서 Fragment 라이프사이클을 직접적으로 조작할 수 있도록 해줍니다.
- Fragment를 테스트하기 위해서는 테스트하고자 하는 Fragment의 FragmentScenario를 생성해주어야 합니다.
🌹 파라미터로 Theme를 전달하는 이유는?
원래 Fragment는 상위(parent) activity에서 theme를 가져와 사용합니다. 하지만 FragmentScenario는 activity와는 독립적으로 테스트할 수 있도록 empty activity 내에서 launch됩니다.
(* 한국어로 어떻게 해석해야할지 모르겠네요.ㅎㅎ launch가 제일 확실해요😂)
launchFragmentInContainer 코드를 열어보면 아래와 같습니다.
public inline fun <reified F : Fragment> launchFragmentInContainer(
fragmentArgs: Bundle? = null,
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
initialState: Lifecycle.State = Lifecycle.State.RESUMED,
factory: FragmentFactory? = null
): FragmentScenario<F> = FragmentScenario.launchInContainer(
F::class.java, fragmentArgs, themeResId, initialState,
factory
)
주어진 파라미터로 아무런 동작을 하지 않는(empty) FragmentActivity를 만들고, 해당 Activity의 root view container(여기서는 R.id.content)에 Fragment를 launch 시키고, 전달된 initialState(여기서는 Lifecycle.State.RESUMED) 상태가 될 때까지 대기합니다.
8.4 테스트 실행시켜보기
이번 테스트는 Instrumented test이기 때문에, Emulator나 실제 기기를 준비해야 합니다.
테스트를 실행시켜보면
- instrumented test이기 때문에 실제 기기에서 실행
- 테스트하고자 하는 fragment만 화면에 launch
🌹 실제 실행시켜보면 화면이 깜빡!하고 사라지는 것을 알 수 있습니다...🤔
잠시나마 화면을 확인하기 위해 Thread.sleep()을 사용할 수 있습니다.
👇🏻 테스트 결과
화면은 잘 나왔습니다. 하지만 분명 taskId를 argument로 전달해주었을 텐데 No data가 뜨는 걸까요?
TaskDetailFragment에서 실질적으로 사용하는 Repository에 해당 Task의 데이터가 없기 때문입니다.
다음에는 이 repository를 이전에 만들어두었던 FakeTestRepository로 교체하여 테스트해보도록 하겠습니다.
9. ServiceLocator 만들기
이 코드랩에서는 ServiceLocator를 이용하여 Fragment에 FakeRepository를 주입해주도록 하겠습니다.
Fragment에서는 이전과 같이 생성자 의존성 주입을 사용할 수 없습니다.😭
(* 생성자 의존성 주입을 사용하려면 우리가 그 객체를 생성(construct)할 수 있어야 하는데, Fragment와 같은 안드로이드 component는 생성자의 직접 접근할 수 없기 때문입니다.)
때문에 ServiceLocator pattern를 대신 사용하여 의존성을 주입해주도록 하겠습니다.
ServiceLocator pattern은 "ServiceLocator"라는 Singleton 클래스를 만들고, 이 클래스에서 실제 코드와 테스트 코드 모두에 의존성을 제공해주는 디자인 패턴입니다.
이번 Chapter에서는
- ServiceLocator 클래스를 만들고 Repository를 생성하고 보관할 수 있도록 하겠습니다.
- Repository가 필요한 곳에서 ServiceLocator를 사용하도록 코드를 수정하겠습니다.
- 테스트 코드에서 ServiceLocator를 사용해 실제 repository를 FakeRepository로 교체해주겠습니다.
9.1 ServiceLocator 만들기
🌹 ServiceLocator는 production과 test 코드 모두에서 사용되므로 main source set에 위치해야 합니다.
ServiceLocator는 main source set의 최상위 디렉토리에 만들도록 하겠습니다.
// singleton이므로 object 클래스 정의
object ServiceLocator {
private var database: ToDoDatabase? = null
// 여러 thread에서 사용될 수 있기 때문에 @Volatile 어노테이션을 붙여줍니다.
@Volatile
var tasksRepository: TasksRepository? = null
}
이제 Repository와 관련된 함수들을 작성해보도록 하겠습니다.
Repository는 한번 객체를 생성해두고 동일한 객체를 반환해도 동작을 하는데 무관하므로,
처음에는 새로운 객체를 만들고 이후에는 만들어둔 객체를 반환해주도록 하겠습니다.
작성해야 하는 함수는 아래 4가지가 있습니다.
9.1.1 createDataBase
- 새롭게 데이터베이스를 만들어줍니다.
private fun createDataBase(context: Context): ToDoDatabase {
val result = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java,
"Tasks.db"
).build()
database = result
return result
}
9.1.2 createTaskLocalDataSource
- 새로운 LocalDataSource를 만들어줍니다.
private fun createTaskLocalDataSource(context: Context): TasksDataSource {
val newDatabase = database ?: createDataBase(context)
return TasksLocalDataSource(newDatabase.taskDao())
}
9.1.3 createTasksRepository
- 새로운 Repository를 만들어줍니다.
private fun createTasksRepository(context: Context): TasksRepository {
val newRepo = DefaultTasksRepository(
TasksRemoteDataSource,
createTaskLocalDataSource(context)
)
tasksRepository = newRepo
return newRepo
}
9.1.4 provideTasksRepository
- repository 객체를 반환해주는 함수로, 객체를 새로 만들거나 기존에 만들어둔 객체를 반환합니다.
🌹 여러 thread에서 동시에 접근하여 객체를 생성하는 것을 막기 위해 synchronized(this)를 사용하여 동시성을 제공하도록 하겠습니다.
fun provideTasksRepository(context: Context): TasksRepository {
synchronized(this) {
return tasksRepository ?: createTasksRepository(context)
}
}
9.2 ServiceLocator로 교체해보자
ServiceLocator는 어플리케이션의 어디서나 의존성을 제공해주기 위해 어플리케이션의 어디서나 접근 가능해야 합니다.
어떻게 이러한 기능을 제공해줄 수 있을까요?
🍿 바로 어플리케이션 package 구조의 최상단에 위치하는 Application 클래스의 멤버로 정의해주면, applicationContext로 접근가능한점을 이용할 수 있습니다.
9.2.1 Application 클래스 만들기
class TodoApplication : Application() {
val tasksRepository: TasksRepository
get() = ServiceLocator.provideTasksRepository(this)
}
9.2.2 DefaultTasksRepository
- ServiceLocator에서 Repository 객체를 생성해주므로, DefaultTasksRespository의 getRepository 메서드를 제거해줍니다.
9.2.3 TaskDetailFragment
- DefaultTasksRepository를 제거해주었으니, 이를 사용하고 있던 쪽의 코드도 변경해주어야 합니다.
- 기존에 getRepository를 호출했던 부분을 ServiceLocator로 변경하도록 하겠습니다.
// 이전
private val viewModel by viewModels<TaskDetailViewModel> {
TasksDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
// 변경 후
private val viewModel by viewModels<TaskDetailViewModel> {
TasksDetailViewModelFactory(
(requireContext().applicationContext as TodoApplication).tasksRepository
)
}
9.3 FakeAndroidTestRepository 만들기
이전에 Repository 테스트를 위해 FakeTestRepository를 만들었었지만, 기본적으로 test와 androidTest source set 사이에서는 클래스를 공유할 수 없습니다.
때문에, androidTest를 위한 FakeRepository 클래스를 하나 더 만들어주도록 하겠습니다.
이름은 FakeAndroidTestRepository!
class FakeAndroidTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private var shouldReturnError = false
private val observableTasks = MutableLiveData<Result<List<Task>>>()
fun setReturnError(value: Boolean) {
shouldReturnError = value
}
override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
if (shouldReturnError) {
return Result.Error(Exception("Test Exception"))
}
return Result.Success(tasksServiceData.values.toList())
}
override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
if (shouldReturnError) {
return Result.Error(Exception("Test Exception"))
}
tasksServiceData[taskId]?.let {
return Result.Success(it)
}
return Result.Error(Exception("Task Not Found"))
}
override suspend fun refreshTasks() {
observableTasks.value = getTasks()
}
override suspend fun refreshTask(taskId: String) {
refreshTasks()
}
override fun observeTasks(): LiveData<Result<List<Task>>> {
runBlocking { refreshTasks() }
return observableTasks
}
override fun observeTask(taskId: String): LiveData<Result<Task>> {
runBlocking { refreshTasks() }
return observableTasks.map { tasks ->
when (tasks) {
is Result.Loading -> Result.Loading
is Result.Success -> {
val task = tasks.data.firstOrNull { it.id == taskId }
?: return@map Result.Error(Exception("Not Found"))
Result.Success(task)
}
is Result.Error -> Result.Error(tasks.exception)
}
}
}
override suspend fun saveTask(task: Task) {
tasksServiceData[task.id] = task
}
override suspend fun completeTask(task: Task) {
val completedTask = Task(task.title, task.description, true, task.id)
tasksServiceData[task.id] = completedTask
}
override suspend fun completeTask(taskId: String) {
throw NotImplementedError()
}
override suspend fun activateTask(task: Task) {
val activeTask = Task(task.title, task.description, false, task.id)
tasksServiceData[task.id] = activeTask
}
override suspend fun activateTask(taskId: String) {
throw NotImplementedError()
}
override suspend fun clearCompletedTasks() {
tasksServiceData = tasksServiceData.filterValues { it.isCompleted.not() }
as LinkedHashMap<String, Task>
}
override suspend fun deleteAllTasks() {
tasksServiceData.clear()
runBlocking { refreshTasks() }
}
override suspend fun deleteTask(taskId: String) {
tasksServiceData.remove(taskId)
runBlocking { refreshTasks() }
}
fun addTasks(vararg tasks: Task) {
for (task in tasks) {
tasksServiceData[task.id] = task
}
runBlocking { refreshTasks() }
}
}
9.4 테스트에서 ServiceLocator 사용하기
main source set에 있는 ServiceLocator를 테스트에서 사용하기 위해서는 몇 가지 추가 작업을 해줘야 합니다. 😟
9.4.1 @VisibleForTesting
- ServiceLocator 내 tasksRepository 변수의 setter에 @VisibleForTesting 어노테이션을 추가해주어야 합니다.
- 이는 test를 위해 setter를 public으로 설정하겠다는 의미입니다.
만약 main source set에서 taskRepository의 setter에 접근하려고 하면 아래와 같이 경고가 나타날 것입니다.
여기서 주의해야할 점은 각 테스트들끼리 동일한 Repository를 사용하지 않도록 하는 것입니다.
현재 ServiceLocator는 Singleton으로 되어있기 때문에 테스트들끼리 동일한 객체를 쉽게 공유할 수 있기 때문입니다.
이를 위해, 새로운 테스트를 실행할 때 ServiceLocator를 reset해주는 새로운 메서드를 만들어 사용할 수 있습니다.
taskRepository를 초기화하는 부분과 마찬가지로 여러 thread에서 접근할 수 있으므로 synchronized를 이용해 동시성을 제공해주어야 힙니다.🤗
👇🏻 요렇게
// ServiceLocator.kt
@VisibleForTesting
fun resetRepository() {
synchronized(lock) {
runBlocking {
TasksRemoteDataSource.deleteAllTasks()
}
// Clear all data to avoid test pollution
database?.apply {
clearAllTables()
close()
}
database = null
tasksRepository = null
}
}
이러한 reset 메서드는 테스트가 종료되는 시점에 호출해줄 수 있습니다.
9.4.2 ServiceLocator 사용하기
👇🏻 아래와 같이 초기 설정을 해주고
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanUpDb() = runTest {
ServiceLocator.resetRepository()
}
실제 테스트 코드를 수정해주면
@Test
fun activeTaskDetails_DisplayedInUi() = runTest {
// 1. Given: Add active (incomplete) task to the DB
val activeTask = Task("ActiveTask", "AndroidX Rocks", false)
repository.saveTask(activeTask)
// 2. When: TaskDetailFragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
Thread.sleep(2000)
}
이렇게 이전과는 다르게 argument로 넘겨준 task가 정상적으로 화면에 출력된 것을 알 수 있습니다.
👏🏻👏🏻👏🏻 성공적으로 Fragement를 (시각적으로) 테스트 해보았습니다.
이번에는 테스트를 위해 ServiceLocator를 사용하였습니다.
이후 추가적으로 Hilt를 사용하여 테스트하는 방법도 학습해봐야겠네요.🔥
다음은 이번 코드랩의 마지막으로 Espresso와 Mockito를 이용하는 방법에 대해서
공부해볼 예정입니다.
감사합니다.😌
'Android > Android' 카테고리의 다른 글
[Hilt] Component와 Scope 무엇이 다른지 디버깅으로 다 찍어보자! (0) | 2023.02.11 |
---|---|
[Android:Codelab] Dependency Injection and Test Doubles - 5 (0) | 2023.01.26 |
[Android:Codelab] Dependency Injection and Test Doubles - 3 (0) | 2023.01.25 |
[Android:Codelab] Dependency Injection and Test Doubles - 2 (0) | 2023.01.24 |
[Android:Codelab] Dependency Injection and Test Doubles - 1 (0) | 2023.01.24 |