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

나고수·2022년 10월 23일
0

codelab

목록 보기
3/5

Android TDD CodeLab 02.Introduction to Test Doubles and Dependency Injection 1~5

Concept: Testing Strategy

Testing Strategy에 대해 고려해야 할 것.

  • 범위 - 테스트에서 얼마나 많은 코드가 영향을 줍니까? single method를 테스트 할수도 있고, 전체 애플리케이션을 테스트 할 수도 있고, 일부 코드를 테스트 할 수 있습니다.
  • 속도 - 테스트가 얼마나 빨리 실행됩니까? 테스트 속도는 밀리초에서 몇 분까지 다양할 수 있습니다.
  • Fidelity—테스트가 얼마나 "실제"입니까? 예를 들어, 테스트 중인 코드의 일부가 네트워크 요청을 해야 하는 경우 테스트 코드가 실제로 이 네트워크 요청을 합니까, 아니면 결과를 위조합니까? 테스트가 실제로 네트워크와 통신하는 경우 충실도가 더 높다는 의미입니다. 단점은 테스트를 실행하는 데 시간이 더 오래 걸리고 네트워크가 다운된 경우 오류가 발생하거나 사용 비용이 많이 들 수 있다는 것입니다.

자동화된 테스트를 나누는 범주

  • 단위(Unit) 테스트 - 단일 클래스, 일반적으로 해당 클래스의 단일 메서드에서 실행되는 고도로 집중된 테스트입니다. 단위 테스트가 실패하면 코드에서 문제가 있는 위치를 정확히 알 수 있습니다.
    현실에서는 앱이 하나 이상의 (많은) 클래스나 메서드를 실행 하기 때문에 정확도가 낮습니다.
    코드를 변경할 때마다 실행할 수 있을 만큼 빠릅니다.
    대부분 로컬에서 실행되는 테스트입니다.
    예: viewModel 및 repository에서 단일 메서드 테스트.
  • 통합(Integration) 테스트 - 여러 클래스의 상호 작용을 테스트하여 함께 사용할 때 예상대로 작동하는지 확인합니다.
    통합 테스트를 구성하는 한 가지 방법은 작업 저장 기능과 같은 단일 기능을 테스트하도록 하는 것입니다.
    단위 테스트보다 더 넓은 범위의 코드를 테스트하지만 완전한 충실도를 갖는 것보다 빠르게 실행하도록 최적화되어 있습니다.
    상황에 따라 로컬로 실행하거나 계측 테스트로 실행할 수 있습니다.
    예: 단일 fragment 및 viewModel 쌍의 모든 기능 테스트.
  • 종단 간(End to end) 테스트(E2e) - 함께 작동하는 기능 조합을 테스트합니다.
    앱의 많은 부분을 테스트하고 실제와 비슷하게 전체적으로 작동되므로 일반적으로 느립니다. Fidelity가 가장 높습니다.
    대체로 이러한 테스트는 계측 테스트가 될 것입니다.
    예: 전체 앱을 시작하고 몇 가지 기능을 함께 테스트합니다.

목표 : DefaultTasksRepository의 getTasks 메소드를 테스트하는 것

Task: Make a Fake Data Source

클래스의 일부(메소드 또는 작은 메서드 모음)에 대한 단위 테스트를 작성할 때 목표는 해당 클래스의 코드만 테스트하는 것입니다.
DefaultTaskRepository.kt를 봅시다.
이 repository의 메소드를 단위 테스트 하고 싶지만, LocalTaskDataSource 및 RemoteTaskDataSource가 DefaultTasksRepository에 종속되어 있기 때문에 이 repository의 함수만 테스트 하는 것은 쉽지 않습니다.

DefaultTaskRepository의 메소드를 테스트 하기 위해, DataBase를 만들어야 합니까?
그러면 이 테스트는 로컬 테스트 일까요 계측 테스트 일까요?
TasksRemoteDataSource에 종속되어 있는 함수를 테스트 하기 위해 실제 네트워크와 통신을 해야할까요?

TestDouble

이런 문제를 해결하기 위해, TestDouble이라는 것을 알아봅시다.
테스트 더블은 테스트를 위해 특별히 제작된 클래스입니다. 테스트에서 클래스의 실제 버전을 대체하기 위한 것입니다.

Android에서 사용되는 가장 일반적인 테스트 더블은 Fakes와 Mocks입니다.

이 작업에서는 실제 DataSource에서 분리된 DefaultTasksRepository 단위 테스트를 위해 FakeDataSource testDouble을 만들 것입니다.

Step 1: Create the FakeDataSource class

Step 2: Implement TasksDataSource Interface

새 클래스 FakeDataSource를 testDouble로 사용하려면 TasksLocalDataSource와 TasksRemoteDataSource를 대체 할 수 있어야 한다.
두 DataSource 모두 TasksDataSource를 구현하고 있다. 따라서, FakeDataSource도 TasksDataSource를 구현하자. 그리고 모든 메소드를 override하자.

class FakeDataSource : TasksDataSource {

}

Step 3: Implement the getTasks method in FakeDataSource

FakeDataSource는 Fake라고 하는 특정 유형의 testDouble입니다. Fake는 클래스의 "작동" 구현이 있는 테스트 더블이지만 테스트에는 적합하지만 프로덕션에는 적합하지 않은 방식으로 구현됩니다. "작업" 구현은 클래스가 입력이 주어지면 실제 출력을 생성한다는 것을 의미합니다.

예를 들어 Fake 데이터 원본은 네트워크에 연결하거나 데이터베이스에 아무 것도 저장하지 않고 메모리 내 목록만 사용합니다. 이것은 작업을 가져오거나 저장하는 메서드가 예상한 결과를 반환한다는 점에서 "예상한 대로 작동"하지만 서버나 데이터베이스에 저장되지 않기 때문에 프로덕션 환경에서는 이 구현을 사용할 수 없습니다.

//데이터베이스 또는 서버 응답을 "Fake" 처리하는 작업 목록입니다. 
//현재 목표는 저장소의 getTasks 메소드를 테스트하는 것입니다. 
//이것은 데이터 소스의 getTasks, deleteAllTasks 및 saveTask 메소드를 호출합니다.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }
package com.example.android.architecture.blueprints.todoapp.data.source

import androidx.lifecycle.LiveData
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Task

//DefaultTasksRepository를  테스트하기 위해
//tasksRemoteDataSource와 tasksLocalDataSource를 대체하는 'fake' dataSource
//tasksRemoteDataSource와 tasksLocalDataSource 둘다 TasksDataSource를 implement함
//FakeDataSource도 TasksDataSource를 Implement 하자

//fake test double ->
// fake는 클래스의 "작동" 구현이 있는 테스트 더블
// 테스트에는 적합하지만 프로덕션에는 적합하지 않은 방식으로 구현됩니다.
// "작업" 구현은 클래스가 입력이 주어지면 실제 출력을 생성한다는 것을 의미합니다.

class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource {
    override fun observeTasks(): LiveData<Result<List<Task>>> {
        TODO("Not yet implemented")
    }

    override suspend fun getTasks(): Result<List<Task>> {
        //작업이 null이 아닌 경우 성공 결과를 반환합니다. 작업이 null이면 오류 결과를 반환합니다.
        tasks?.let { return Result.Success(ArrayList(it)) }
        return Result.Error(
            Exception("Tasks not found")
        )
    }

    override suspend fun refreshTasks() {
        TODO("Not yet implemented")
    }

    override fun observeTask(taskId: String): LiveData<Result<Task>> {
        TODO("Not yet implemented")
    }

    override suspend fun getTask(taskId: String): Result<Task> {
        TODO("Not yet implemented")
    }

    override suspend fun refreshTask(taskId: String) {
        TODO("Not yet implemented")
    }

    override suspend fun saveTask(task: Task) {
        tasks?.add(task)
    }

    override suspend fun completeTask(task: Task) {
        TODO("Not yet implemented")
    }

    override suspend fun completeTask(taskId: String) {
        TODO("Not yet implemented")
    }

    override suspend fun activateTask(task: Task) {
        TODO("Not yet implemented")
    }

    override suspend fun activateTask(taskId: String) {
        TODO("Not yet implemented")
    }

    override suspend fun clearCompletedTasks() {
        TODO("Not yet implemented")
    }

    override suspend fun deleteAllTasks() {
        tasks?.clear()
    }

    override suspend fun deleteTask(taskId: String) {
        TODO("Not yet implemented")
    }
}

Task: Write a Test Using Dependency Injection

현재 종속성은 DefaultTasksRepository의 init 메소드 내에서 구성됩니다.
DefaultTasksRepository 내에서 taskLocalDataSource 및 tasksRemoteDataSource를 만들고 할당하기 때문에 기본적으로 하드 코딩됩니다. tasksRemoteDataSource와 tasksLocalDataSource을 FakeDataSource로 바꿀 수 없습니다.

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

    init {
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }
    // Rest of class
}

클래스 내에서 직접 dataSource를 생성하는 대신에, 생성자에서 수동 종속성 삽입을 통해 dataSource를 주입합니다.

Step 1: Use Constructor Dependency Injection in DefaultTasksRepository

// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }

// WITH
//1. 생성자에서 종속성 주입 
class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
//2. 더 이상 init 에서 datasource를 만들지 않으므로 init 메소드 삭제
//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
                }
            }
        }
    }

Step 2: Use your FakeDataSource in your tests - DefaultTasksRepositoryTest.kt 만들기

DefaultTasksRepository를 block 지정 후 command+N -> test 에 테스트 클래스를 만든다.

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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        //fakeDataSource를 이용해서 테스트용 remote,local dataSource 만들기 
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // fakeDataSource를 이용하여 테스트용 tasksRepository만들기 
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

Step 3: Write DefaultTasksRepository getTasks() Test

Step 4: Add runBlockingTest

@Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runTest {
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Result.Success
        // Then tasks are loaded from the remote data source
        assertEquals(tasks.data, remoteTasks)
    }

runTest?

getTasks()가 suspend fun 이기 때문에, 코루틴 범위가 필요하다.

  • kotlinx-coroutines-test는 특히 코루틴 테스트를 위한 코루틴 테스트 라이브러리입니다.
  • testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
    -runTest { }
    코드 블록을 받은 다음 동기적으로 즉시 실행되는 특수 코루틴 컨텍스트에서 이 코드 블록을 실행합니다.
    즉, 작업이 결정적 순서로 발생합니다.
    이것은 본질적으로 코루틴이 비 코루틴처럼 실행되도록 하므로 코드를 테스트하기 위한 것입니다.
  • 클래스 위에 @ExperimentalCoroutinesApi를 추가합니다. runTest{}를 사용 한다는 의미.
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.core.IsEqual
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test


@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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

}
profile
되고싶다

0개의 댓글