개발/안드로이드

로컬 테스트 설정

스몰스테핑 2024. 5. 23. 18:33

이전 글에서 이어지는 내용입니다.

https://small-stepping.tistory.com/957

 

종속 항목 삽입

이전글에서 이어지는 내용입니다.https://small-stepping.tistory.com/956 UI 레이어와 데이터 레이어 분리레이어를 분리하는 이유코드를 여러 레이어로 분리하면 앱의 확장성이 높아지며 앱이 더 견고해

small-stepping.tistory.com

 

 

 

로컬 테스트 설정

이전 글들에서 UI 레이어와 데이터 레이어 분리 및 종속 항목 삽입으로 ViewModel에서 REST API 서비스와의 직접적인 상호작용을 추상화하는 저장소 구현에 이르기 까지 MarsPhotos 앱을 리펙터링하였다. 이 과정을 통해 목적이 제한된 작은 코드 조각을 테스트할 수 있다.

 

기능이 제한된 작은 코드를 테스트하는 것은 여러 기능을 갖춘 대규모 코드용으로 작성한 테스트보다 빌드하고 구현하고 이해하는 것이 쉽다.

 

 

로컬 테스트 종속 항목 추가

app/build.grandle.kts에 다음 종속 항목을 추가한다.

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")

 

 

로컬 테스트 디렉토리 만들기

프로젝트 뷰에서 src 디렉토리를 마우스 우클릭 하여 New > Directory > test/java를 선택. 로컬 테스트 디렉토리를 만든다.

테스트 디렉토리에 com.example.marsphotos라는 새 패키지를 만든다.

 

 

테스트용 모조 데이터 및 종속 항목 만들기

각 로컬 테스트는 한 가지만 테스트해야 한다. 예를 들어 뷰 모델의 기능을 테스트할 때 저장소 또는 API 서비스의 기능은 테스트하고 싶지 않을 수 있다. 마찬가지로, 저장소를 테스트할 때 API 서비스는 테스트하고 싶지 않을 수 있다.

 

인터페이스를 사용하고 이후에 종속 항목 삽입을 사용하여 이러한 인터페이스에서 상속되는 클래스를 포함하면 테스트 목적으로만 만들어진 모조 클래스를 사용하여 이러한 종속 항목의 기능을 시뮬레이션할 수 있다. 테스트를 위해 모조 클래스와 데이터 소스를 삽입하면 반복성과 일관성을 유지하면서 코드를 개별적으로 테스트할 수 있다.

 

object FakeDataSource {

   const val idOne = "img1"
   const val idTwo = "img2"
   const val imgOne = "url.1"
   const val imgTwo = "url.2"
   val photosList = listOf(
       MarsPhoto(
           id = idOne,
           imgSrc = imgOne
       ),
       MarsPhoto(
           id = idTwo,
           imgSrc = imgTwo
       )
   )
}
  1. 테스트 디렉토리에서 com.example.marsphotos 아래에 fake 라는 패키지를 만든다.
  2. fake 디렉토리에서 FakeDataSource 라는 새 kotlin 객체를 만든다.
  3. 이 객체에서 MarsPhoto 객체 목록으로 설정된 속성을 만든다. 목록을 길지는 않지만, 적어도 2개 이상은 포함해야한다.

 

이후, 이 튜토리얼의 기준이 되는 프로젝트는 저장소가 API 서비스에 종속되므로 저장소 테스트를 만들기 위해 방금 만든 모조 데이터를 반환하는 모조 API 서비스가 있어야한다. 모조 API 서비스가 저장소에 전달되면 모조 API 서비스의 메서드가 호출될 때 저장소가 모조 데이터를 수신한다.

package localTest.marsphotos.fake

import com.example.marsphotos.network.MarsApiService
import com.example.marsphotos.network.MarsPhoto

class FakeMarsApiService: MarsApiService {
    override suspend fun getPhotos(): List<MarsPhoto> {
        return FakeDataSource.photosList
    }
}
  1. fake 패키지에서 FakeMarsApiService라는 새 클래스를 만든다.
  2. MarsApiService 인터페이스에서 상속하도록 FakeMarsApiService 클래스를 설정한다.
  3. getPhotos() 함수를 재정의하고, 가짜 사진 목록을 반환시킨다.

위 과정을 거친 후, 프로젝트 디렉토리 상황

 

 

저장소 테스트 작성

NetworkMarsPhotosRepository 클래스의 getMarsPhotos() 메서드를 테스트한다.

  1. 모조 디렉토리에 NetworkMarsRepositoryTest 라는 새 클래스를 만든다.
  2. 해당 클래스에 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()라는 새 메서드를 만든 뒤 @Test 주석을 붙인다.
  3. NetworkMarsPhotosRepository 인스턴스를 만들고 marsApiService 매개변수로 FakeMarsApiService를 전달한다.
  4. getMarsPhotos() 메서드에서 반환하는 데이터가 FakeDataSource.photosList와 같다고 어설션한다.
package localTest.marsphotos.fake

import com.example.marsphotos.data.NetworkMarsPhotosRepository
import junit.framework.Assert.assertEquals
import org.junit.Test

class NetworkMarsRepositoryTest {
    @Test
    fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() {
        val repository = NetworkMarsPhotosRepository(
            marsApiService = FakeMarsApiService()
        )
        assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
    }
}

 

그러면 다음과 같은 에러가 발생한다.

정지 함수 'getMarsPhotos'의 호출은 코루틴 or 또 다른 정지 함수에서만 허용된다.

 

class NetworkMarsPhotosRepository(
   private val marsApiService: MarsApiService
) : MarsPhotosRepository {
   /** Fetches list of MarsPhoto from marsApi*/
   override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}

 

data/MarsPhotosRepository.kt에서 살펴보면 ViewModel에서 해당 함수 호출 시, viewmodelScope.launch()에 전달된 람다에서 호출하여 코루틴에서 이 메서드를 호출하기 때문에, 테스트 시에도 코루틴에서 정지 함수도 호출해야한다. 하지만 접근 방식은 다르다.

 

 

테스트 코루틴

gradle app 수준에서 다음 항목을 종속시킨다.

androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")

 

 

이후 메서드를 다음과 같이 수정한다.

package localTest.marsphotos.fake

import com.example.marsphotos.data.NetworkMarsPhotosRepository
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test

class NetworkMarsRepositoryTest {
    @Test
    fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() = runTest {
        val repository = NetworkMarsPhotosRepository(
            marsApiService = FakeMarsApiService()
        )
        assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
    }
}

 

해당 테스트의 오류는 사라지고, 실행해보면 정상적으로 통과하는 것을 확인할 수 있다.

 

 

ViewModel 테스트 작성

ViewModel에서 데이터를 받아오는 함수의 테스트를 작성한다. ViewModel은 Repository에 종속된다. 따라서 이 테스트를 진행하기 위해 FakeRepository를 만들어야한다.

 

 

모조 저장소 만들기

Repository 인터페이스에서 상속되고 모조 데이터를 반환하도록 데이터 반환 함수를 정의하는 모조 클래스를 만든다. 이 접근 방식은 모조 API 서비스를 사용했을 때의 접근 방식과 유사하며 이 클래스가 기존에 작성한 MarsApiService 대신 MarsPhotosRepository 인터페이스를 확장한다는 점이 다르다.

 

  1. fake 디렉토리에 FakeNetworkMarsPhotosRepository 라는 새 클래스를 만든다.
  2. MarsPhotosRepository 인터페이스로 해당 클래스를 확장시킨다.
  3. getMarsPhotos() 함수를 재정의 하고, FakeDataSource.photosList를 반환한다.
package localTest.marsphotos.fake

import com.example.marsphotos.data.MarsPhotosRepository
import com.example.marsphotos.network.MarsPhoto

class FakeNetworkMarsPhotosRepository: MarsPhotosRepository {
    override suspend fun getMarsPhotos(): List<MarsPhoto> {
        return FakeDataSource.photosList
    }
}

 

 

ViewModel 테스트 작성

    1. MarsViewModelTest 라는 새 클래스를 만든다.
    2. marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() 라는 함수를 만들고 @Test 주석을 추가한다.
package localTest.marsphotos.fake

import com.example.marsphotos.ui.screens.MarsUiState
import com.example.marsphotos.ui.screens.MarsViewModel
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test

class MarsViewModelTest {
    @Test
    fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() = runTest {
        val marsViewModel = MarsViewModel(FakeNetworkMarsPhotosRepository())
        assertEquals(
            MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars photos retrieved"),
            marsViewModel.marsUiState
        )
    }
}

 

하지만 이대로 실행하면 오류가 발생한다.

Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

 

MarsViewModel이 viewModelScope.launch()를 사용해 저장소를 호출한다. 해당 명령은 Main 디스패처라는 기본 코루틴 디스패처 아래에 새 코루틴을 실행 시킨다. Main 디스패처는 Android UI 스레드를 래핑한다. 앞의 오류는 단위 테스트에서 Android UI 스레드를 사용할 수 없기 때문에 발생한 오류이다.

 

단위 테스트는 Android 기기나 에뮬레이터가 아닌 워크스테이션에서 실행되므로, 로컬 단위 테스트 아래 코드가 Main 디스패처를 참조하는 경우 위와 같은 오류가 발생한다. 해당 문제를 해결하기 위해 단위 테스트 실행시 기본 디스패처를 명시적으로 정의해야 한다.

 

 

테스트 디스패처 만들기

Main 디스패러는 UI 컨텍스트에서만 사용 가능하므로, 단위 테스트 친화적인 디스패처로 바꿔야 한다. Kotlin 코루틴 라이브러리는 이를 위해 TestDispatcher라는 코루틴 디스패처를 제공한다. 뷰 모델의 getMarsPhotost() 함수와 마찬가지로 새 코루틴이 생성되는 단위 테스트에는 Main 디스패처 대신 TestDispatcher를 사용해야 한다.

 

모든 경우 Main 디스패처를 TestDispatcher로 바꾸려면 Dispatchers.setMain() 함수를 사용한다.

 

Dispatchers.resetMain() 함수를 사용해 스레드 디스패처를 다시 Main 디스패처로 재설정할 수 있다. 각 테스트에서 Main 디스패처를 대체하는 코드가 중복되지 않도록 JUnit 테스트 규칙으로 추출할 수 있다. TestRule은 테스트가 실행되는 환경을 제어하는 방법을 제공한다. TestRule은 검사를 추가하거나, 테스트에 필요한 설정 또는 정리를 실행하거나, 테스트 실행을 관찰하여 다른 곳에 보고할 수 있다. 테스트 클래스 간에 쉽게 공유 가능하다.

 

  1. Main 디스패처를 대처하는 TestRule을 작성할 전용 클래스를 만든다.
  2. 테스트 디렉토리에 rules 라는 새 패키지를 만든다.
  3. 규칙 디렉토리에 TestDispatcherRule 라는 새 클래스를 만든다.
  4. TestWatcher를 사용해 TestDispatcherRule을 확장한다. TestWatcher 클래스를 사용하면 테스트의 다양한 실행 단계에서 작업을 실행할 수 있다.
  5. TestDispatcherRule의 TestDispatcher 생성자 매개변수를 만든다.
  6. 테스트 규칙으로 테스트 실행 전 Main 디스패처를 테스트 디스패처로 바꾸게 하기 위해 starting() 함수를 재정의 한다.
  7. Dispatchers.setMain() 호출을 추가하고, testDispatcher를 인수로 전달한다.
  8. 테스트 실행이 완료되면 finished() 메서드를 재정의해 Main 디스패처를 재설정한다. Dispatchers.resetMain()을 호출한다.
package localTest.rules

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class TestDispatcherRule(
    private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
): TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}
package localTest.marsphotos

import com.example.marsphotos.ui.screens.MarsUiState
import com.example.marsphotos.ui.screens.MarsViewModel
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.test.runTest
import localTest.marsphotos.fake.FakeDataSource
import localTest.marsphotos.fake.FakeNetworkMarsPhotosRepository
import localTest.rules.TestDispatcherRule
import org.junit.Rule
import org.junit.Test

class MarsViewModelTest {
    @JvmField
    @Rule
    val testDispatcher = TestDispatcherRule()

    @Test
    fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() = runTest {
        val marsViewModel = MarsViewModel(FakeNetworkMarsPhotosRepository())
        assertEquals(
            MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars photos retrieved"),
            marsViewModel.marsUiState
        )
    }
}

 

실행해서 테스트를 통과하면 된다.

 

다만 필자는 실행하자마자 버그와 마주쳤다.

 

https://small-stepping.tistory.com/959

 

에러: Execution failed for task ':app:compileDebugAndroidTestKotlin'.

다음과 같은 에러가 발생하였다. org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':app:compileDebugKotlin'. at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.lambda$executeIfValid$1(ExecuteActionsTaskE

small-stepping.tistory.com

 

https://small-stepping.tistory.com/960

 

에러: Didn't find class "androidx.test.runner.AndroidJUnitRunner" on

다음과 같은 에러가 발생하였다. java.lang.RuntimeException: Unable to instantiate instrumentation ComponentInfo{xxx.yyy.test/android.support.test.runner.AndroidJUnitRunner}: java.lang.ClassNotFoundException: Didn't find class "android.support.

small-stepping.tistory.com

 

다음 두 과정을 거쳐 수정후 테스트에 통과하였다.