[Android TDD CodeLab 02.Introduction to Test Doubles and Dependency Injection] part2

나고수·2022년 10월 23일
0

codelab

목록 보기
5/5

Android TDD CodeLab 02.Introduction to Test Doubles and Dependency Injection Tasks. 6~9

Task: Set up a Fake Repository

목표 : viewModel에 대한 단위 및 통합 테스트

Step 1. Create a TasksRepository Interface

TaskRemoteDataSource와 TaskLocalDataSource가 모두 TasksDataSource를 구현하고 있었기 때문에, FakeDataSource 역시 TasksDataSource를 구현하였습니다.
이번에도 마찬가지로, DefaultTasksRepository와 FakeTasksRepository가 공동의 인터페이스를 구현하게 해야 합니다.
따라서, DefaultTasksRepository에서 TasksRepository라는 인터페이스를 뽑아냅니다.
DefaultTasksRepository의 모든 공개 메소드만을 포함해야 합니다.
아래의 방법으로 interface를 추출하고 나면, DefaultTasksRepository는 TasksRepository를 구현하게 됩니다.
interface 추출하는 법 참고

Step 2. Create FakeTestRepository

TasksRepository를 구현하는 FakeTasksRepository를 만듭니다. - test Source set에 만들어야함.

class FakeTestRepository : TasksRepository  {
}

Step 3. Implement FakeTestRepository methods

Step 4. Add a method for testing to addTasks

FakeTestRepository는 FakeDataSources 또는 이와 유사한 것을 사용할 필요가 없습니다. 입력이 주어지면 현실적인 가짜 출력을 반환하기만 하면 됩니다. LinkedHashMap을 사용하여 작업 목록을 저장하고 관찰 가능한 작업에 대해 MutableLiveData를 사용합니다.

package com.example.android.architecture.blueprints.todoapp.data.source

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking


class FakeTestRepository : TasksRepository {

    var taskServiceData: LinkedHashMap<String, Task> = LinkedHashMap() //현재 작업 목록
    private val observableTasks = MutableLiveData<Result<List<Task>>>() //관찰 가능한 작업

    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 observableTasks
    }

    override suspend fun completeTask(task: Task) {
       val completedTask = task.copy(isCompleted = true)
       tasksServiceData[task.id] = completedTask
       refreshTasks()
     }
     
     //테스트를 위해 미리 task 넣어놓는 함수
    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            taskServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }  

    // Rest of class

}

Task: Use the Fake Repository inside a ViewModel

목표 : viewModel 안에서 FakeRepository 사용하기.

Step 1. Make and use a ViewModelFactory in TasksViewModel

tasksRepository를 viewModel 내부에서 생성하는 대신, 생성자에서 주입 받도록 변경합니다.

// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() { 
    // Rest of class 
}

생성자를 변경했으므로 이제 factory를 사용하여 TasksViewModel을 생성해야 합니다.

class TaskViewModelFactory(
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return (TasksViewModel(tasksRepository) as T)
    }
}

TasksFragment.kt 업데이트

// REPLACE
private val viewModel by viewModels<TasksViewModel>()

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}

Step 2. Use FakeTestRepository inside TasksViewModelTest

이제 repository를 viewModel 생성자에서 주입하기 때문에
viewModel에 fakeRepository를 만들어서 주입합니다.
testDouble에서는 대리자나 viewModelProvider 없이 그냥 바로 viewModel 생성자에 repository를 주입할 수 있습니다.

@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

    // Use a fake repository to be injected into the viewmodel
    private lateinit var tasksRepository: FakeTestRepository
    private lateinit var tasksViewModel: TasksViewModel
    
    // Rest of class
}
    @Before
    fun setUpViewModel() {
        tasksRepository = FakeTestRepository()
        val task1 = Task("Title1", "Description1")
        val task2 = Task("Title2", "Description2", true)
        val task3 = Task("Title3", "Description3", true)
        tasksRepository.addTasks(task1, task2, task3)
        //There's no need to use the delegate property or a ViewModelProvider,
        // you can just directly construct the ViewModel in unit tests.
        tasksViewModel = TasksViewModel(tasksRepository)
    }

Step 3. Also update TaskDetailFragment and ViewModel

// 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 }


@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과 Fragment 쌍을 테스트 하기
지금까지 한 것 : viewModel에 FakeRepository 주입

Task: Launch a Fragment from a Test

목표 : fragment 및 viewModel 상호 작용을 테스트하기 위한 통합 테스트를 작성합니다. viewModel 코드가 UI를 적절하게 업데이트하는지 확인할 수 있습니다.

Step 1. Add Gradle Dependencies

  // 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"

junit:junit—JUnit, which is necessary for writing basic test statements.
androidx.test:core—Core AndroidX test library
kotlinx-coroutines-test—The coroutines testing library
androidx.fragment:fragment-testing—AndroidX test library for creating fragments in tests and changing their state.

Step 2. Make a TaskDetailFragmentTest class

TaskDetailFragment.kt 를 열고 command + N 으로 테스트 클래스를 만듭니다.
계측 테스트를 위하여 androidTest에 만듭니다.

  • 왜 계측 테스트?
    fragment(적어도 테스트할 fragment)는 시각적이며 사용자 인터페이스를 구성합니다.
    이 때문에 화면에 렌더링해서 테스트 하는 것이 도움이 됩니다.
    따라서 fragment를 테스트할 때 일반적으로 androidTest 소스 세트에 있는 계측 테스트를 작성합니다.

annotaion 작성

//실행할 테스트 크기를 그룹화하고 선택하는 데 도움이 됩니다.
//SmallTest - 단위테스트
//MediumTest - 통합테스트
//LargeTest - E2E테스트

@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

}

Step 3. Launch a fragment from a test - FragmentScenario

  • a class from AndroidX Test that wraps around a fragment
  • 테스트를 위해 fragment의 수명 주기를 직접 제어할 수 있습니다.
    fragment에 대한 테스트를 작성하려면 테스트 중인 프래그먼트(TaskDetailFragment)에 대한 FragmentScenario를 만듭니다.
    @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
        //Creates a Bundle, which represents the fragment arguments for the task that get passed into the fragment).
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        //FragmentScenario 생성
        //The launchFragmentInContainer function creates a FragmentScenario, with this bundle and a theme.
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
		//parent activity와 연관성 없이 이 fragment만 독립적으로 실행되는 테스트입니다.
        //따라서, style을 따로 지정해줘야합니다.
    }

Task: Make a ServiceLocator

목표 : ServiceLocator를 사용하여 fragment에 fake Repository를 제공합니다.
이렇게 하면 fragment와 viewModel 통합 테스트를 할 수 있습니다.

문제점 : TaskDetailFragment를 테스트 하려면 , TaskDetailViewModel에서 만들어지는 repository를 fakeRepository로 바꿔줘야 합니다.
지금까지 했던 것 처럼, fragment 생성자에 viewModel을 받으면 어떨까요?
fragment를 구성하지 않기 때문에-직접 만들지 않기 때문에? 아니면 fragment에 파라미터를 넣는건 원칙적으로 불허용이라?-, TaskDetailViewModel 종속정 주입이 어렵습니다.

해결책 : 서비스 로케이터(Service Locator) 사용
싱글 톤 클래스를 만드는 것을 포함하며, 그 목적은 일반 코드와 테스트 코드 모두에 종속성을 제공하는 것이다.

Step 1. Create the ServiceLocator

package com.example.android.architecture.blueprints.todoapp

import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.room.Room
import com.example.android.architecture.blueprints.todoapp.data.source.DefaultTasksRepository
import com.example.android.architecture.blueprints.todoapp.data.source.TasksDataSource
import com.example.android.architecture.blueprints.todoapp.data.source.TasksRepository
import com.example.android.architecture.blueprints.todoapp.data.source.local.TasksLocalDataSource
import com.example.android.architecture.blueprints.todoapp.data.source.local.ToDoDatabase
import com.example.android.architecture.blueprints.todoapp.data.source.remote.TasksRemoteDataSource
import kotlinx.coroutines.runBlocking

//singleTone->object
//하고 싶은 것 : fragment에서 생성되는 viewModel에는 repository가 종속되어 있다.
// 그 viewModel에 viewModel(fakeRepository)를 넣고싶다.
//하지만 코드내부에서 뷰모델이 만들어진다. & fragment에 viewModel을 생성자 삽입 하는건 어렵다.
//따라서 일반/테스트 코드에 모두 종속성 삽입되는 serviceLocator을 만들어서
//상황에 따라 database와 repository를 다르게 만들자.

//싱글톤 클래스 이므로 oject로 만든다.
object ServiceLocator {

    private var database: ToDoDatabase? = null

    @Volatile //https://jw910911.tistory.com/56
    var tasksRepository: TasksRepository? = null
        @VisibleForTesting set //setter가 pulic인 이유는 테스트 때문이다.

    //테스트를 단독으로 실행하든, 여러 테스트 그룹에서 실행하든, 테스트는 정확히 동일하게 실행되어야 합니다.
    // 즉, 테스트에 서로 종속적인 동작이 없어야 합니다(즉, 테스트 간에 개체를 공유하지 않아야 함).
    // Service Locator는 싱글톤이므로 테스트 간에 실수로 공유될 가능성이 있습니다.
    // 이 문제를 방지하려면 테스트 간의 Service Locator 상태를 올바르게 재설정하는 방법을 만드십시오.
    private val lock = Any()

    @VisibleForTesting
    fun resetRepository() {
        synchronized(lock) {
            runBlocking {
                TasksRemoteDataSource.deleteAllTasks()
            }
            database?.apply {
                clearAllTables()
                close()
            }
            database = null
            tasksRepository = null
        }
    }

    //이미 존재하는 리포지토리를 제공하거나 새 리포지토리를 생성합니다.
    // 여러 스레드가 실행되는 상황에서 실수로 두 개의 저장소 인스턴스를 만드는 상황을 방지하려면 이 메서드를 synchronized켜야 합니다.
    fun provideTasksRepository(context: Context): TasksRepository {
        synchronized(this) {
            return tasksRepository ?: createTasksRepository(context)
        }
    }

    //새로운 tasksRepository 만든다.
    private fun createTasksRepository(context: Context): TasksRepository {
        val newRepo =
            DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
        tasksRepository = newRepo
        return newRepo
    }

    private fun createTaskLocalDataSource(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
    }

}

Step 2. Use ServiceLocator in Application

class TodoApplication : Application() {

    //repository instance를 싱글톤으로 만들기 위해, application단에서 서비스 로케이터 사용
    val tasksRepository: TasksRepository
        get() = ServiceLocator.provideTasksRepository(this)

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(DebugTree())
    }
}

repository 생성을 serviceLocator에서 하기 때문에 repository 만드는 코드 삭제

// 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
            }
        }
    }
}

repository 가져오는 부분 serviceLocator 이용하도록 수정

//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)
}

//TaskFragment.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.kt & AddEditTaskViewModel.kt 수정
// REPLACE this code
    private val tasksRepository = DefaultTasksRepository.getRepository(application)


// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

Step 3. Create FakeAndroidTestRepository

androidTest에서 FakeAndroidTestRepository.kt를 만듭니다.

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking
import java.util.LinkedHashMap


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 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 Error -> Error(tasks.exception)
                is Success -> {
                    val task = tasks.data.firstOrNull() { it.id == taskId }
                        ?: return@map Error(Exception("Not found"))
                    Success(task)
                }
            }
        }
    }

    override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        tasksServiceData[taskId]?.let {
            return Success(it)
        }
        return Error(Exception("Could not find task"))
    }

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        return Success(tasksServiceData.values.toList())
    }

    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) {
        // Not required for the remote data source.
        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
        } as LinkedHashMap<String, Task>
    }

    override suspend fun deleteTask(taskId: String) {
        tasksServiceData.remove(taskId)
        refreshTasks()
    }

    override suspend fun deleteAllTasks() {
        tasksServiceData.clear()
        refreshTasks()
    }

   
    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }
}

Step 4. Prepare your ServiceLocator for Tests

 //테스트를 단독으로 실행하든, 여러 테스트 그룹에서 실행하든, 테스트는 정확히 동일하게 실행되어야 합니다.
    // 즉, 테스트에 서로 종속적인 동작이 없어야 합니다(즉, 테스트 간에 개체를 공유하지 않아야 함).
    // Service Locator는 싱글톤이므로 테스트 간에 실수로 공유될 가능성이 있습니다.
    // 이 문제를 방지하려면 테스트 간의 Service Locator 상태를 올바르게 재설정하는 방법을 만드십시오.
    private val lock = Any()

    //db와 repository를 모두 null로 만드는 함수 
    @VisibleForTesting //setter가 public인 이유가 테스트 때문임을 표현하는 annotation
    fun resetRepository() {
        synchronized(lock) {
            runBlocking {
                TasksRemoteDataSource.deleteAllTasks()
            }
            database?.apply {
                clearAllTables()
                close()
            }
            database = null
            tasksRepository = null
        }
    }

Step 5. Use your ServiceLocator

//TaskDetailFragmentTest.kt
@MediumTest
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

    private lateinit var repository: TasksRepository

	//repository를 fakeRepository로 설정
    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

   ////serviceLocator reset
    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }


    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

}

여기까지 정리 . 선 밑으로 보면 됨

profile
되고싶다

0개의 댓글