[codelab 5.2 - 4, 5 ,6 ,7] Repository Unit Test  (Fake Datasource with DI)
Android/Android Test

[codelab 5.2 - 4, 5 ,6 ,7] Repository Unit Test (Fake Datasource with DI)

728x90
반응형

우리가 일부 클래스(혹은 함수, 함수 모음등)에 대한 단위(unit)테스트를 수행할때 목표는 해당 클래스의 코드만 테스트하는것입니다. 

하지만 특정 클래스의 코드만 작성하는것은 매우 까다롭습니다.

 

그 이유를 Repository 클래스를 예로들어 살펴보겠습니다. 

목표는 해당 클래스의 코드 만 테스트하는 것인데 Repository는 작동하기 위해 LocalDataSource 및 RemoteDataSource와 같은 다른 클래스에 의존합니다. 다시말해 LocalTaskDataSource 및 RemoteTaskDataSource가 DefaultTaskRepository의 종속되어 있다는 것입니다. (DataSource are dependencies of Repository)

 

DefaultTasksRepository의 모든 메서드는 DataSource 클래스의 메서드를 호출하고, 그리고 그 DataSource 클래스는 또 다른 클래스의 메서드를 호출하여 정보를 데이터베이스에 저장하거나 네트워크와 통신합니다.

 

예를 들어 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에 대해 수행할 수 있는 가장 "기본적인" 호출 중 하나입니다. 이 방법에는 SQLite 데이터베이스에서 읽고 네트워크 호출(updateTasksFromRemoteDataSource에 대한 호출)이 포함됩니다. 여기에는 Repository 코드보다 훨씬 더 많은 코드가 포함됩니다.

 

 

정리해서 Repository 테스트가 어려운 이유는 

  • repository에 대한 가장 간단 한 테스트도 수행하려면 데이터베이스를 만들고 관리하는 것에 대해 생각해야합니다. 이렇게하면 "현지 또는 계측 테스트 여야합니까?", "AndroidX 테스트를 사용하여 시뮬레이션 된 Android 환경을 가져와야하는가"와 같은 고민들이 생기게 됩니다.
  • 네트워킹 코드와 같은 코드의 일부는 실행하는 데 오랜 시간이 걸리거나 때로는 실패하여 장기간 실행되는 비정상적인 테스트를 생성 할 수 있습니다.

 

Flaky 테스트란?

더보기

동일한 코드에서 반복적으로 실행될 때 통과할 때도 있고 실패할 때도 있는 테스트입니다. 가능하면 피하십시오.
이러한  테스트 실패로 인해 어떤 코드에 결함이 있는지 진단하는 기능이 테스트에서 손실될 수 있습니다. 테스트에서 비저장소 코드 테스트를 시작할 수 있으므로 예를 들어 데이터베이스 코드와 같은 일부 종속 코드의 문제로 인해 가정된 "저장소" 단위 테스트가 실패할 수 있습니다.

Test Doubles(테스트 더블) 이란?

더보기

테스트 더블은 테스트를 위해 특별히 제작 된 클래스 버전입니다.
테스트에서 클래스의 실제 버전을 대체하기위한 것입니다.
실제 객체를 대신해서 테스팅에서 사용하는 모든 방법을 일컬여 호칭하는 것입니다. 
영화 촬영시 스턴트 더블이 배우를 대신해서 위험한 역할을 대신하는데서 비롯되었습니다. 

  • Fake
    • 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체이다.
    • 동작의 구현을 가지고 있지만 실제 프로덕션에는 적합하지 않은 객체이다.
    • 다시말해 동작은 하지만 실제 사용되는 객체처럼 정교하게 동작하지는 않는 객체를 말한다.
  • Mock
    • 호출에 대한 기대를 명세하고 내용에 따라 동작하도록 프로그래밍 된 객체이다.
    • Mockito 프레임워크가 대표적인 Mock 프레임워크라 볼 수 있다.
    • 메서드를 동작했을 때 어떤 결과를 반환할지를 결정할 수 있다.
    • Mockito의 when 메서드에서 정확한 값을 사용해서 특정 상황에 대한 테스트를 특정할 수 있다.
  • Dummy
    • 인터페이스의 구현체는 필요하지만, 특정 테스트에서 해당 구현체의 동작이 필요하지 않은 경우 사용한다.
    • 예를들어 로그용 경고를 출력하는 테스트 동작같은 경우 테스트 환경에서는 필요하지 않다.
    • 함수에 코드 내용 없이 구현됨
    • 메서드에 코드없이 TaskRepository를 구현합니다.
  • Stub
    • Dummy 객체가 실제로 동작하는 것 처럼 보이게 만들어 놓은 객체이다.
    • 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태이다.
    • 테스트에서 호출된 요청에 대해 미리 준비해둔 결과를 제공한다.
    • 테스트를 위해 프로그래밍된 내용에 대해서만 준비된 결과를 제공하는 객체이다.
  • Spy
    • Stub의 역할을 가지면서 호출된 내용에 대해 약간의 정보를 기록한다.
    • 테스트 더블로 구현된 객체에 자기 자신이 호출 되었을 때 확인이 필요한 부분을 기록하도록 구현한다.
    • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에 대해서는 Stub로 만들어서 동작을 지정할 수도 있다.
    • 실제 객체로도 사용할 수 있고 Stub 객체로도 활용할 수 있으며 필요한 경우 특정 메서드가 제대로 호출되었는지 여부를 확인할 수 있는 객체이다. 

이중 안드로이드에서 가장 많이 사용되는 Test double은 Fack 와 Mock 입니다. 

이제부터 실제 데이터 소스에서 분리된 DefaultTasksRepository 단위 테스트를 위해 

FakeDataSource '테스트 더블'을 만들 것입니다.

 

 

요약

  1. 단위 테스트(Unit Test)는 일반적으로 단일 클래스를 테스트하는 고도로 집중된 테스트입니다.
  2. DefaultTasksRepository는 TasksLocalDataSource와 TasksRemoteDataSource라는 두 가지 복잡한 종속성(Dependency)이 있기 때문에 단위 테스트가 어렵습니다.
  3. 이를 해결하기 위해 테스트할 때 TasksLocalDataSource 및 TasksRemoteDataSource를 대체하도록 '테스트 더블'(가짜 DataSource)을 만듭니다.
  4. 이렇게 하면 DefaultTasksRepository 코드만 테스트하는 Unit 테스트를 작성할 수 있습니다.

4 Task: 가짜 DataSource 만들기

Make a Fake Data Source


1 단계: FakeDataSource 클래스 만들기

 테스트 소스 세트에 data 패키지를 만들고 그 안에 FakeTasksDataSource 클래스를 만든다. 

 

이 FakeTasksDataSource 클래스는 테스트 더블중에 fack 입니다. 
fack는 클래스에 "working(작동하는) 실행 코드"를 갖고 있어, 테스트에는 좋지만 production에는 적합하지 않는 방식으로 실행됩니다. 여기서 "working 실행 코드"란 클래스가 주어진 입력에 대해 realistic한 출력을 생성한다는것을 의미합니다. 
예를 들어 FakeDataSource는 네트워크에 연결하거나 데이터베이스에 아무것도 저장하지 않습니다. 대신 메모리상에 있는 데이터목록을 이용합니다. 이것은 작업을 가져 오거나 저장하는 방법이 예상 결과를 반환한다는 점에서 "예상대로 작동"합니다. 하지만 production 환경에서는이 구현을 사용할 수 없습니다. 왜냐하면 서버 나 데이터베이스에 저장되지 않기 때문입니다.

2 단계: TasksDataSource 인터페이스 구현

새 클래스 FakeDataSource를 테스트 더블로 사용하려면 다른 데이터 소스를 대체할 수 있어야 합니다.

FaceDataSource가 대체할 클래스는 TasksLocalDataSource 및 TasksRemoteDataSource입니다. 

TasksLocalDataSource 와 TasksRemoteDataSource는 모두 FakeDataSource를 상속받아서 구현되어졌습니다.

class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }

TasksDataSource도 인터페이스를 상속받아서 FakeDataSource를 만들면

asksLocalDataSource 와 TasksRemoteDataSource를 대체할 수 있습니다.

class FakeDataSource : TasksDataSource {

}

3단계: FakeDataSource에서 getTasks 메서드 구현

이 FakeDataSource는 실제 데이터베이스 나 네트워크에 의존하지 않고도

DefaultTasksRepository에서 코드를 테스트 할 수 있고,

또한 테스트를위한 "real-enough"한 구현을 제공합니다.

 

실제 코드에서 TasksLocalDataSource와 TasksRemoteDataSource는 TasksDataSource클래스를 상속받아서 구현되기때문에에 FakeDataSource도 TasksDataSource 인터페이스를 상속받고 필요한 함수들을 오버라이드(override) 합니다. 

그리고 FakeDataSource 생성자를 변경하여  MutableList <Task>? 형식의 tasks 변수를 만듭니다.

그리고  오버라이드한 메소드 함수들을 구현합니다. 

getTasks()함수 : 작업이 null이 아닌 경우 성공 결과를 반환합니다. 작업이 null이면 오류 결과를 반환합니다.
deleteAllTasks 작성 : 변경 가능한 작업 목록을 지 웁니다.
saveTask 작성 : 목록에 작업을 추가합니다.

import androidx.lifecycle.LiveData
import com.example.android.architecture.blueprints.todoapp.data.source.TasksDataSource
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success

class FakeTasksDataSource(
        var tasks: MutableList<Task>? = mutableListOf()
) : TasksDataSource {

    override suspend fun getTasks(): Result<List<Task>> {
        tasks?.let { return Success(ArrayList(it)) }
        return Error(
                Exception("Tasks not found")
        )
    }

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

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

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        TODO("Not yet implemented")
    }
//........
}

 

 


5 Task: 종속성 주입(DI)을 사용하여 테스트 작성

Write a Test Using Dependency Injection


이 단계에서는 방금 만든 가짜 테스트 더블을 사용할 수 있도록 수동 종속성 주입이라는 기술을 사용할 것입니다.

주요 문제는 FakeDataSource가 있지만 테스트에서 사용하는 방법이 불분명하다는 것입니다. TasksRemoteDataSource 및 TasksLocalDataSource를 교체해야 하지만 테스트에서만 가능합니다. TasksRemoteDataSource와 TasksLocalDataSource는 모두 DefaultTasksRepository의 종속성입니다. 즉, DefaultTasksRepositories가 실행하려면 이러한 클래스가 필요하거나 "의존"합니다.

현재 종속성은 DefaultTasksRepository의 init 메소드 내부에 구성되어 있습니다.

DefaultTasksRepository.kt

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
}

DefaultTasksRepository 내에서 taskLocalDataSource 및 tasksRemoteDataSource를 만들고 할당하기 때문에 기본적으로 하드 코딩됩니다. 테스트 더블을 교환할 방법이 없습니다.

대신 하고 싶은 것은 이러한 데이터 소스를 하드 코딩하는 대신 클래스에 제공하는 것입니다. 종속성을 제공하는 것을 종속성 주입이라고 합니다. 종속성을 제공하는 다양한 방법이 있으므로 다양한 유형의 종속성 주입이 있습니다.

생성자 DI를 사용하면 Test Double을 생성자에 전달하여 스왑할 수 있습니다.

 

 

1 단계: DefaultTasksRepository에서 생성자 DI 사용

 

기존 코드(no injection 코드)를 

class DefaultTasksRepository (application: Application) { // 생성자를 통해 필요한 객체 삽입
	// 인스턴스 변수를 삭제(생성자에서 정의)
    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 {	// 종속성을 전달했기 때문에 init 메소드를 제거
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }

//........
}

다음 코드(injection 코드)처럼 구현한다. 

class DefaultTasksRepository(
        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) {
                    val database = Room.databaseBuilder(app,
                            ToDoDatabase::class.java, "Tasks.db")
                            .build()
                    DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                        INSTANCE = it
                    }
                }
            }
        }
        
 //.....       
 }

2단계: DefaultTasksRepositoryTest에서 FakeDataSource 사용 하면서 getTasks() 테스트 작성

이제 DefaultTasksRepository 코드에서 생성자 종속성 주입을 사용하고 있으므로, fake data source를 사용하여 DefaultTasksRepository를 테스트 할 수 있습니다.

 

1. 테스트 소스 세트에서 DefaultTasksRepositoryTest를 생성합니다.

2. 세 개의 변수와 두 개의 FakeDataSource 멤버 변수(리포지토리의 각 데이터 소스에 대해 하나씩), 테스트할 DefaultTasksRepository에 대한 변수를 만듭니다.

(테스트 가능한 DefaultTasksRepository를 설정하고 초기화하는 방법을 만드십시오. 이 DefaultTasksRepository는 테스트 더블인 FakeDataSource를 사용할 것입니다.)

3. createRepository라는 메서드를 만들고 @Before로 주석을 답니다.
4. remoteTasks 및 localTasks 목록을 사용하여 가짜 데이터 소스를 인스턴스화합니다.
5. 방금 생성한 두 개의 가짜 데이터 소스와 Dispatchers.Unconfined를 사용하여 taskRepository를 인스턴스화합니다.

 

6. 저장소의 getTasks 메소드에 대한 테스트를 작성하십시오. true로 getTasks를 호출할 때(즉, 원격 데이터 소스에서 다시 로드해야 함) 원격 데이터 소스(로컬 데이터 소스와 반대)에서 데이터를 반환하는지 확인하십시오.(호출하면 코루틴 에러 발생)

 

 

 

4단계: runBlockingTest 추가

getTasks가 중단(suspend) 함수이고 이를 호출하려면 coroutine을 launch해야 하기 때문에 coroutine 오류가 예상됩니다. 

이를 위해서는 coroutine scope가 필요합니다. 이 오류를 해결하려면 테스트에서 coroutine launching을 처리하기 위해 몇 가지 gradle 종속성을 추가해야 합니다.

 

coroutine의 suspend function을 test하기 위해서 라이브러리에서 제공하는 runBlockingTest를 사용해야합니다. 

 

1. coroutines라이브러리를 사용하기위해 app / build.gradle에 종속성을 추가

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

DefaultTasksRepositoryTest클래스에 @ExperimentalCoroutinesApi 어노테이션 추가해줍니다. 

 

 

DefaultTasksRepositoryTest 클래스 최종 코드는 다음과 같습니다. 

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

import com.example.android.architecture.blueprints.todoapp.data.FakeDataSource
import com.example.android.architecture.blueprints.todoapp.data.Result
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 }

    // 두 개의 FakeDataSource 멤버 변수 및 DefaultTasksRepository테스트 할 변수 총 3개의 변수를 작성
    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource
    private lateinit var tasksRepository: DefaultTasksRepository// Class under test

    @Before
    fun createRepository() {
        // 가짜 데이터 소스를 인스턴스화함
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())

        // tasksRepository를 인스턴스화함(방금 만든 두 개의 FakeDataSource와 Dispatchers.Unconfined를 사용)
        tasksRepository = DefaultTasksRepository(
                // Dispatchers.Unconfined는 Dispatchers.Main으로 바꿈
                //이것은 코 루틴 + 테스트에 대해 더 많이 이해해야합니다. 지금은 이것을 Unconfined로 유지
                tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest{
        // When : Repository에서 작업이 요청되는 경우
        val tasks = tasksRepository.getTasks(true) as Result.Success

        // Then : tasks가 remote data source에서 로드되는지 여부 확인
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

}

 

 

 

 

 


6 Task: 가짜 리포지토리 설정

Set up a Fake Repository


 

view model에 대한 단위테스트를 작성하려합니다.

단위테스트는 관심있는 "단위"를 명확히 격리해서 해당 범위의 코드만 테스트해야합니다.

따라서 TasksViewModelTest도 위의 DefaultTasksRepositoryTest 와같이 종속성주입을 진행하려합니다. 

TasksViewModelTest는 데

 

1 단계. TasksRepository 인터페이스 생성

 

 

2 단계. FakeTestRepository 생성

 

 

3 단계. FakeTestRepository 메서드 구현

 

 

4 단계. addTasks에 테스트 방법 추가

 

 

 

 

 


7 Task: ViewModel 내부에서 Fake Repository 사용

Use the Fake Repository inside a ViewModel

 


1 단계. TasksViewModel에서 ViewModelFactory 만들기 및 사용

 


2 단계. TasksViewModelTest 내에서 FakeTestRepository 사용

 

 

3 단계. TaskDetailFragment 및 ViewModel도 업데이트합니다.

 

 

 

 

 

참고

woowacourse.github.io/javable/post/2020-09-19-what-is-test-double/

 

728x90
반응형