Android ViewMoel Test 해보기

정대영·2025년 3월 28일

Android Test

목록 보기
1/2
post-thumbnail

앞에서 자동 테스트란 무엇이고 로컬 테스트, 게층 테스트란 무엇인지 살펴보았다.
모른다면 공부하는 것을 추천한다 -> 링크

그렇다면 실제로 우리가 작성하는 코드에서 테스트 코드를 작성하는 방법을 알아보자.

대부분의 사람들이 MVVM 패턴에 클린 아키텍처를 적용할 것이다.
뷰의 상태를 저장하고 있는 ViewModel 에서 api 통신이 후 뷰에서 옵저빙하는 상태의 변화를 살펴보자.
실제 프로젝트를 진행하는 도중 내가 작성한 코드가 api 통신이 잘 되는지, 통신 이 후 부모델의 상태가 변하고 리컴포지션이 일어나는지 확인하려면 앱을 처음부터 발드하고 API를 호출하는 화면까지 이동해야되는 번거로움이 있고 이 과정까지 얼마나 시간이 걸릴진 알 수 없다…
이러한 불편함을 해결하고 생산성을 향상시키기 위해 ViewModel 을 테스트하는 코드를 같이 살펴보자
(UI 테스트가 아니라는 점 주의바랍니다.)

build.gradle.kts 설정

dependencies {
	androidTestImplementation("junit:junit:4.13.2)
    // AndroidJUnit4::class 를 사용하기 위함
    androidTestImplementation("androidx.test.ext:junit-ktx:1.1.3)
}

@RunWith()

@RunWith(AndroidJUnit4::class) 은 계측 테스트에 사용되는 애너테이션이다.

@RunWith(AndroidJUnit4::class)
class RegisterBusScheduleViewModelTest {}

가끔 androidx.test.runner.AndroidJUnit4를 사용할 수 있는데 Deprecated 되었으니 잘 참고하자.
androidx.test.ext.junit.runners.AndroidJUnit4를 사용해야 된다!

ViewModel 생성?

우리가 클린 아키텍쳐를 적용하여 프로젝트를 할 때 ViewModel -> UseCase -> Repository 와 같은 흐름으로 local 혹은 remote와 데이터 전송이 이뤄지는 것을 알 것이다. 대부분 ViewModel의 매개변수로 있는 UseCase를 Hilt를 사용해서 의존성 주입을 하게 될 것이고 ViewModelFactory와 같은 불편함을 겪지 않을 것이다.

@HiltViewModel
class RegisterBusScheduleViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val readAllBusStopUseCase: ReadAllBusStopUseCase,
    savedStateHandle: SavedStateHandle,
)

class ReadAllBusStopUseCase @Inject constructor(private val busStopRepository: BusStopRepository) {
    suspend operator fun invoke(cityName: String, nodeId: String) = runCatchingIgnoreCancelled {
        busStopRepository.readAllBusStop(cityName, nodeId)
    }
}

interface BusStopRepository {
    suspend fun readAllBusStop(cityName: String, nodeId: String): List<BusStopInfo>
}

그러나 TestCode를 작성할 때는 ViewModel을 개발자가 직접 생성해야 한다. 어떻게 생성하는게 좋을까?

FakeRepository를 만들어서 테스트하면 된다! FakeRepository를 만들면 직접 서버와 통신하지 않아도 테스트를 할 수 있다는 장점이 있다.

class FakeBusStopRepository: BusStopRepository {
    override suspend fun readAllBusStop(cityName: String, nodeId: String): List<BusStopInfo> {
        return listOf(BusStopInfo(name = "모란고개", nodeId = "GGB204000087", tmX =37.4370333, tmY = 127.1285333))
    }
}

BusStopRepository를 구현하는 FakeBusStopRepository를 만들고 readAllBusStop() 에 예상되는 결과값을 리턴으로 넘겨준다.

Test Code 작성

@RunWith(AndroidJUnit4::class)
class RegisterBusScheduleViewModelTest {

    lateinit var viewModel: RegisterBusScheduleViewModel
    lateinit var readAllBusStopUseCase: ReadAllBusStopUseCase

    @OptIn(ExperimentalCoroutinesApi::class)
    @Before
    fun setUp() {
        readAllBusStopUseCase = ReadAllBusStopUseCase(FakeBusStopRepository())
        viewModel = RegisterBusScheduleViewModel(
            context = getApplicationContext(),
            readAllBusStopUseCase,
            savedStateHandle = SavedStateHandle()
        )
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun registerBusScheduleViewModel_inputRegionAndBusStop_returnBusStopInfoList() = runTest {
    	//Given
        val region = "성남시"
        val busStopName = "GGB204000087"
        
        
        // when
        viewModel.kakaoMap = KakaoMapObject()
        viewModel.fetchReadAllBusStop(region = region, busStopNodeId = busStopName, {}){}

        advanceUntilIdle()
        
        // Then
        val result = viewModel.kakaoMap.getLabels()
        val expect = listOf(BusStopInfo(name = "모란고개", nodeId = "GGB204000087", tmX =37.4370333, tmY = 127.1285333))
        assertEquals(expect, result)
    }
}

advanceUntilIdle() 메서드는 코루틴 스코프 내부에 있는 또 다른 코루틴 스코프가 끝날 때까지 기다리도록 하는 메서드이다.
비동기 처리를 동기화 시키다고 볼 수 있다. 위 코드에서 advanceUntilIdle()가 없다면 fetchReadAllBusStop()가 끝나기 전에 viewModel.kakaoMap.getLabels() 코드를 실행되고 예상치 못한 결과를 얻게 된다. 이 문제를 해결하기 위해 사용한다.

한 가지 주의 할점으로 advanceUntilIdle()은 같은 Dispatcher내에서 동작하는 코루틴 스코프에 한정되어 동기적인 흐름으로 바꿔준다는 사실을 기억하자

@HiltViewModel
class RegisterBusScheduleViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val readAllBusStopUseCase: ReadAllBusStopUseCase,
    savedStateHandle: SavedStateHandle,
) {
	fun fetchReadAllBusStop(
    	region: String,
    	busStopNodeId: String,
    	changeLoadingState: () -> Unit,
        showToast: (String) -> Unit,
    ) {
        viewModelScope.launch {
            readAllBusStopUseCase( cityName = region, nodeId = busStopNodeId ).onSuccess { busStop ->
            	// kakaoMap 객체의 labels들에 busStop을 저장하는 코드
        }.onFailure {}
    }
}

MockK 사용해 Test 해보기

FakeRepository를 작성하는 방법은 TestCode 작성하는데 있어서 ViewModel-UseCase-Repository간의 종속성을 해결해주는 좋은 방벙이었다.

그러나 FakeRepository를 만드는 이 마저도 귀찮게 느껴질 수 있다. 그럴 떄 Mockk를 사용하면 보단 간단하게 의존성을 해결하고 테스트를 할 수 있다.

Fake, Mockk 이 방법들은 전부 Mocking(모킹)의 종류중 하나라고 한다.

Mocking(모킹)이라는 단어가 생소할 수 있으니 아래의 글을 같이 살펴보자.

모킹은 테스트 중인 단위에 외부 종속성이 있을 때 단위 테스트에 사용되는 프로세스다. 모킹의 목적은 외부 종속성의 동작이나 상태가 아니라 코드에 집중하고 격리하는 것이다. 모킹에서 종속성은 실제 항목의 동작을 시뮬레이션하는 밀접하게 제어되는 교체 객체로 대체된다. 대체 객체에는 페이크, 스텁, 목의 3가지 주요 유형이 있다

  • 페이크(Fakes) : 동일한 인터페이스를 구현하지만 다른 객체와 상호작용하지 않고 실제 코드를 대체하는 객체다. 일반적으로 Fakes는 고정된 결과를 반환하도록 하드코딩된다. 다양한 사용 사례를 테스트하려면 많은 페이크를 도입해야 한다. 페이크를 써서 발생하는 문제는 인터페이스가 바뀌면 해당 인터페이스를 구현하는 모든 페이크들도 수정돼야 한다는 것이다
  • 스텁(Stubs) : 스텁은 특정 입력 집합을 기반으로 특정 결과를 반환하는 객체다. 일반적으로 테스트용으로 프로그래밍된 것 외에는 응답하지 않는다
  • 목(Mocks) : 스텁의 더 정교한 버전이다. 여전히 스텁과 같은 값을 반환하지만 각 메서드를 몇 번 호출해야 하는지, 어떤 순서로 어떤 데이터를 호출해야 하는지에 대한 기대를 갖고 프로그래밍할 수 있다

어느 블로그에서 mock에 대해 잘 정리한 글이 있어 공유해보려고 한다.

mock은 내가 서버로부터 받을 거라고 예상하고 미리 만들어 두는 예상 응답(객체일 수 있음)이다. mocking은 테스트 시 이러한 mock 객체를 사용하기 위해 만들어 테스트를 진행하는 것이다
mock은 테스트 시 실제 객체를 사용하면 의존성이 발생하고 시간이 많이 소요되기 때문에, 일관성 있는 결과를 통해 신뢰성 있는 코드를 만들기 위해 사용한다

MockK 은 모킹을 지원하는 라이브러리 중 하나이다.

@RunWith(AndroidJUnit4::class)
class RegisterBusScheduleViewModelTest {

    lateinit var viewModel: RegisterBusScheduleViewModel
    lateinit var readAllBusStopUseCase: ReadAllBusStopUseCase

    @OptIn(ExperimentalCoroutinesApi::class)
    @Before
    fun setUp() {
    	// 변경된 부분
        readAllBusStopUseCase = mockk()
        viewModel = RegisterBusScheduleViewModel(
            context = getApplicationContext(),
            readAllBusStopUseCase,
            savedStateHandle = SavedStateHandle()
        )
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun registerBusScheduleViewModel_inputRegionAndBusStop_returnBusStopInfoList() = runTest {
    	//Given
        val region = "성남시"
        val busStopName = "GGB204000087"
        // 변경된 부분
        coEvery { readAllBusStopUseCase(region, busStopName) } returns Result.success(
            listOf(
                BusStopInfo(
                    name = "모란고개",
                    nodeId = "GGB204000087",
                    tmX = 37.4370333,
                    tmY = 127.1285333
                )
            )
        )
        
        
        // when
        viewModel.kakaoMap = KakaoMapObject()
        viewModel.fetchReadAllBusStop(region = region, busStopNodeId = busStopName, {}){}

        advanceUntilIdle()
        
        // Then
        val result = viewModel.kakaoMap.getLabels()
        val expect = listOf(BusStopInfo(name = "모란고개", nodeId = "GGB204000087", tmX =37.4370333, tmY = 127.1285333))
        assertEquals(expect, result)
    }
}

모킹이 필요한 객체에 mockk() 메서드를 사용하여 모킹 객체를 만들어 준다.
mockk 라이브러리에서 제공하는 every {} 함수를 통해 실제 리모트 저장소로부터 데이터를 가져오는 I/O 작업의 응답값을 설정한다. 응답값은 Result 객체로 감싸줘야 한다.

그러나 모킹 객체인 readAllBusStopUseCase는 suspend 함수를 갖고 있다. suspend 함수에 대한 응답값을 every {} 로 설정하면 오류 발생하기 때문에 coEvery{}로 설정한다.

MockK는 코루틴을 지원하기 때문에 suspend 함수와 같은 비동기 처리 테스트에 유용하다.!

reference

https://www.telerik.com/products/mocking/unit-testing.aspx
모킹이란? MockK과 Mocktio의 차이를 살펴보자

profile
매일 그리고 꾸준히, 성장하는 개발자가 되자

0개의 댓글