상세 컨텐츠

본문 제목

나만의 안드로이드 앱 만들기(중급자 편) - 테스트 코드 작성하기 (viewModel)

나만의 안드로이드 앱 만들기/중급자

by Victorywskim 2023. 12. 25. 23:59

본문

반응형

이번 편에서는 뷰모델 의 테스트 코드를 작성해보도록 하겠습니다.

 

유틸 클래스의 테스트 코드 작성 방법이 궁금하시다면 아래 링크 참고 부탁드립니다.

 

나만의 안드로이드 앱 만들기(중급자 편) - 테스트 코드 작성하기 (utils)

테스트 코드는 간단하다면 간단하다고 느낄 수도 어렵다면 어렵다고도 느낄 수 있다고 생각합니다. 왜냐하면 어느 범위까지 작성해야 할지 고민이 많이 되기 때문입니다. 이번 편에서는 유틸

victorywskim.tistory.com

 

뷰모델의 테스트 코드는 유틸 클래스의 테스트 코드 방법과 조금 다릅니다.

 

유념해야 할 사항 몇가지가 있습니다.

  • ai 의 도움을 받아서 작성하기는 어렵습니다.
    • 유틸 테스트는 테스트 범주가 특정 클래스의 특정 메소드에 한정되지만, 뷰모델 테스트는 여러 객체들이 상호 작용을 하는 경우가 대부분입니다.
    • 복잡도가 높아 비즈니스 로직과 아키텍쳐를 ai 에게 모두 설명하고 만족할만한 결과를 가져오기에는 어려움이 많습니다.
  • code by code 겠지만 모든 경우의 수를 대비하기 어렵습니다.
    • 경우의 수가 적은 경우에는 큰 무리가 없겠지만, 어느정도 서비스가 커지다보면 경우의 수가 천차만별입니다.
    • 또한 너무 상세한 로직으로 테스트 코드를 작성하다보면 추후 해당 코드 수정 시 그에 맞에 테스트 코드도 모두 수정이 필요합니다.
    • 프리징 된 코드가 아니라면 러프한 테스트 코드가 대부분일수도 있어서 비즈니스 로직의 핵심을 이해하고 있어야 합니다.
  • 비즈니스 로직에 대한 높은 이해도가 필요합니다 .
    • 모든 경우에 대비하기 어렵기 때문에 핵심 요점이 무엇인지를 잘 파악해야 합니다.
    • 예를 들어 상품 정보를 등록할때 입력 받는 정보들이 3개 페이지에서 나눠 받는다고 했을때 필수값이 모두 입력되었는지, 선택값은 입력되지 않아도 등록되는지, 사용자가 직접 입력 가능한 입력값에 유효성 체크가 필요한 경우 유효한지에 대한 내용입니다.
      • 이 경우 각 3개 페이지에 해당하는 뷰모델을 가지고 테스트 코드를 짜는 것이 가장 안전한 방법일 것 입니다.
      • 하지만 만약 mvp 로 빠르게 시장 검증만 하려는 상황이라면 입력값들이 어떻게 변동될지 알수없습니다.
      • 그에 따라 고생하여 짠 테스트 코드에 수정이 발생 할 수 도 있습니다.
      • 이런 경우에는 가장 마지막 페이지에서 입력 받은 정보들의 유효성을 검증하고 등록 성공/실패에 대한 예외 처리가 명확하게 이루어지고 있는지를 먼저 검증하고 그 이후 상품 등록 로직이 프리징되면 그때 앞단에 대한 테스트 코드를 작성하는 것도 하나의 방법이라고 생각합니다.
  • 코루틴 처리가 필요합니다 .
    • 경우에 따라서 서버 통신을 진행해야 하거나 코루틴 기능을 통해 딜레이 등 여러 경우가 있을 것입니다.
    • 또한 로직의 순서를 보장해야 하는 경우도 많기 때문에 코루틴은 거의 필수로 요구되게 됩니다.
  • mock 이 필요합니다 .
    • 테스트 코드 상에서는 로컬 디비에 데이터 저장이 불가한 경우도 있고, 상품 구매와 같이 실제 api 를 실행하기 어려운 경우도 있습니다.
    • 이런 경우 데이터 입/출력을 진행했다고 치고 이 경우에 어떤 값을 반환 받겠다라는 가정이 필요한데 이때 필요한 것이 mock 입니다.
  • 테스트 코드에 적합한 아키텍쳐가 도입되어 있지 않다면 작성이 어려울 수 있습니다.
    • 위 모든 사항이 갖춰진 상태라고 해도 테스트 코드에 적합한 아키텍쳐가 아니라면 뷰모델 테스트는 사실 많이 어렵습니다.
    • 테스트 코드 상에서는 안드로이드 의존성이 최대한 많이 배제되어야 하고, 어쩔수없이 있는 경우라면 mock 으로 처리가 가능한 구조여야 합니다.
    • 개인적으로 클린 아키텍쳐가 테스트 코드에 아주 적합한 아키텍쳐라고 생각하고 실제로도 큰 어려움 없이 잘 작성하여 유지보수하고 있습니다.

 

다음 코드에서 HomeWriteViewModel 를 이용해서 테스트 코드를 작성해보도록 하겠습니다.

 

GitHub - tmvlke/SimpleDutch: 심플더치 프로젝트 입니다.

심플더치 프로젝트 입니다. Contribute to tmvlke/SimpleDutch development by creating an account on GitHub.

github.com

HomeWriteViewModel

테스트 목표은 다음과 같습니다.

  • 더치 페이 내용을 추가하려 합니다.
  • 지출 내용, 총 가격, 참여 인원 이름이 하나 이상 등록되어 있는 상태에서만 "추가하기" 버튼 클릭이 가능해야 합니다.
  • 저장이 완료되었다면 완료에 대한 토스트가 노출되며, 이전 페이지로 이동되어야 합니다.

 

이를 구현하기 위한 과정은 다음과 같습니다.

  • 현재 테스트 디렉터리가 구축되어 있지 않아 테스트 디렉터리 구조에 대한 구성을 진행합니다.
  • 코루틴 사용이 가능한 base 를 구성합니다.
  • 뷰모델 테스트 코드를 작성합니다.
  • 작성된 테스트 코드를 검증합니다.

 

테스트 디렉터리 구조에 대한 구성을 진행합니다.

 

 

 

테스트 로직은 presentation 모듈 내에 작성합니다.

 

  • core
    • 테스트 코드에서 공통적으로 사용이 필요한 필수 로직을 관리합니다.
  • devLab
    • 실험적인 기능에 대한 테스트를 진행합니다.
    • 이 안에 있는 테스트들은 성공/실패가 배포에 영향을 미치지 않습니다.
  • prod
    • 배포에 가장 중요한 테스트들이며, 하나라도 실패 시 배포는 불가합니다.
    • 추후 fastlane 과 같은 ci/cd 를 구축할때 presentation 모듈의 prod 내 있는 테스트가 모두 성공했을때에만 배포가 되는 구조로 진행할 예정입니다.

 

코루틴 사용이 가능한 base 를 구성합니다.

 

 

BaseTest.kt

abstract class BaseTest {

    abstract fun onStart()
    abstract fun onEnd()

    @BeforeEach
    fun processedOnStart() {
        println("onStart: ${DateTimeUtils.convertDate(Date())}")
        onStart()
    }

    @AfterEach
    fun processedOnEnd() {
        println("onEnd: ${DateTimeUtils.convertDate(Date())}")
        onEnd()
    }
}

 

일반 테스트 코드의 공통 양식입니다.

 

다음과 같은 구조 사용하게 될 예정으로 이 클래스를 상속받아 구현한 테스트 클래스에서는 특정 테스트 메서드에 대한 시작과 종료 시의 작업을 간편하게 구성할 수 있습니다.

 

 

BaseCoroutineTest.kt

abstract class BaseCoroutineTest {

    companion object {
        @OptIn(ExperimentalCoroutinesApi::class)
        @JvmField
        @RegisterExtension
        val coroutineExtension = MvvmDefaultTaskExecutorExtension()
    }

    abstract fun onStart()
    abstract fun onEnd()

    @OptIn(ExperimentalCoroutinesApi::class)
    @BeforeEach
    fun processedOnStart() {
        println("onStart: ${DateTimeUtils.convertDate(Date())}")
        Dispatchers.setMain(coroutineExtension.dispatcher)
        onStart()
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @AfterEach
    fun processedOnEnd() {
        println("onEnd: ${DateTimeUtils.convertDate(Date())}")
        Dispatchers.resetMain()
        onEnd()
    }
}

 

뷰모델 테스트와 같이 코루틴을 사용하는 테스트들을 위한 공통된 설정을 제공합니다.

  • coroutineExtension
    • 실험적인 코루틴 API를 사용하기 위한 확장 속성으로, 테스트 클래스에서 사용됩니다.
  • processedOnStart
    • 각 테스트 메서드 실행 전에 호출되며, 디스패처를 설정하고 onStart을 호출합니다.
  • processedOnEnd
    • 각 테스트 메서드 실행 후에 호출되며, 디스패처를 리셋하고 onEnd를 호출합니다.

그 외에는 BaseTest 와 같은 목적입니다.

 

 

MvvmDefaultTaskExecutorExtension.kt

@ExperimentalCoroutinesApi
class MvvmDefaultTaskExecutorExtension : BeforeEachCallback, AfterEachCallback {

    val dispatcher: TestDispatcher = StandardTestDispatcher()

    override fun beforeEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) {
                runnable.run()
            }

            override fun postToMainThread(runnable: Runnable) {
                runnable.run()
            }

            override fun isMainThread(): Boolean {
                return true
            }
        })
        Dispatchers.setMain(dispatcher)
    }

    override fun afterEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(null)
        Dispatchers.resetMain()
    }
}

 

 

이 클래스는 코루틴을 사용하는 테스트에서 안드로이드의 ArchTaskExecutor 및 Dispatchers를 임시로 변경하는 JUnit 5 extension 입니다.

 

  • dispatcher
    • 테스트에서 사용할 가상의 디스패처(TestDispatcher)를 생성합니다.
  • beforeEach
    • 각 테스트 메서드 실행 전에 호출되는 메서드로, ArchTaskExecutor를 가로채서 새로운 TaskExecutor를 설정하고, Dispatchers를 가상의 디스패처로 설정합니다.
  • executeOnDiskIO
    • Disk I/O 작업을 동기적으로 실행합니다.
  • postToMainThread
    • 메인 스레드에 작업을 동기적으로 실행합니다.
  • isMainThread
    • 현재 스레드가 메인 스레드인지 여부를 확인합니다.
  • afterEach
    • 각 테스트 메서드 실행 후에 호출되는 메서드로, ArchTaskExecutor를 원래대로 복원하고, Dispatchers를 초기 상태로 리셋합니다.

extension 은 테스트에서 ArchTaskExecutor와 Dispatchers를 코루틴과 함께 사용할 수 있도록 하는데, 특히 UI 스레드와 백그라운드 스레드 간의 작업을 쉽게 테스트할 수 있도록 도와줍니다.

 

뷰모델 테스트 코드를 작성합니다.

 

우선 전체 테스트 코드는 다음과 같습니다.

@ExperimentalCoroutinesApi
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@DisplayName("더치페이 등록 뷰모델 - 정보 저장 로직 테스트")
class HomeWriteViewModelTest : BaseCoroutineTest() {

    private lateinit var vm: HomeWriteViewModel
    private lateinit var dutchInfoUseCase: DutchInfoUseCase

    private lateinit var dutchInfoRepository: DutchInfoRepository

    private val title = "1차 회식"
    private val amount = "100000"
    private val enterPersonName = "참여자1"

    override fun onStart() {
        dutchInfoRepository = mockk()
        dutchInfoUseCase = DutchInfoUseCase(dutchInfoRepository = dutchInfoRepository)
        vm = HomeWriteViewModel(dutchInfoUseCase = dutchInfoUseCase)
    }

    override fun onEnd() {}

    @Test
    @Order(10)
    @DisplayName(
        "더치 페이 정보를 입력 할때 경우 -> " +
                "지출 내용만 입력 경우 -> " +
                "추가하기 버튼이 활성화 되지 않는 것이 맞는가?"
    )
    fun t10() = runBlocking {
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.title.value = title
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()
        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(false, vm.viewState.value.completeButtonEnabled.value)
    }

    @Test
    @Order(11)
    @DisplayName(
        "더치 페이 정보를 입력 할때 경우 -> " +
                "총 가격만 입력 경우 -> " +
                "추가하기 버튼이 활성화 되지 않는 것이 맞는가?"
    )
    fun t11() = runBlocking {
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.amount.value = amount
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()
        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(false, vm.viewState.value.completeButtonEnabled.value)
    }

    @Test
    @Order(12)
    @DisplayName(
        "더치 페이 정보를 입력 할때 경우 -> " +
                "참여자 목록만 입력 경우 -> " +
                "추가하기 버튼이 활성화 되지 않는 것이 맞는가?"
    )
    fun t12() = runBlocking {
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.enterPersonName.value = enterPersonName
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.setEvent(HomeWriteContract.Event.SaveEnterPersonClicked)
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()
        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(false, vm.viewState.value.completeButtonEnabled.value)
    }

    @Test
    @Order(13)
    @DisplayName(
        "더치 페이 정보를 입력 할때 경우 -> " +
                "지출 내용 및 총 가격만 입력 경우 -> " +
                "추가하기 버튼이 활성화 되지 않는 것이 맞는가?"
    )
    fun t13() = runBlocking {
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.title.value = title
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.viewState.value.amount.value = amount
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()
        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(false, vm.viewState.value.completeButtonEnabled.value)
    }

    @Test
    @Order(14)
    @DisplayName(
        "더치 페이 정보를 입력 할때 경우 -> " +
                "총 가격 및 참여자 목록만 입력 경우 -> " +
                "추가하기 버튼이 활성화 되지 않는 것이 맞는가?"
    )
    fun t14() = runBlocking {
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.amount.value = amount
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.viewState.value.enterPersonName.value = enterPersonName
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.setEvent(HomeWriteContract.Event.SaveEnterPersonClicked)
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()
        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(false, vm.viewState.value.completeButtonEnabled.value)
    }

    @Test
    @Order(15)
    @DisplayName(
        "더치 페이 정보를 입력 할때 경우 -> " +
                "지출 내용 및 참여자 목록만 입력 경우 -> " +
                "추가하기 버튼이 활성화 되지 않는 것이 맞는가?"
    )
    fun t15() = runBlocking {
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.title.value = title
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.viewState.value.enterPersonName.value = enterPersonName
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.setEvent(HomeWriteContract.Event.SaveEnterPersonClicked)
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()
        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(false, vm.viewState.value.completeButtonEnabled.value)
    }



    @Test
    @Order(20)
    @DisplayName(
        "더치 페이 정보 입력 완료 후 -> " +
                "추가하기 버튼을 눌렀을 때 -> " +
                "데이터 저장 후 토스트 노출 및 이전 페이지로 이동 되는 것이 맞는가?"
    )
    fun t20() = runBlocking {
        /***************************************************
         * given - 가정
         ***************************************************/
        coEvery {
            dutchInfoRepository.insertDutchInfo(
                DutchListItemVO(
                    title = title,
                    amount = amount,
                    enterPersonList = arrayListOf(
                        DutchPersonVO(
                            name = enterPersonName,
                            isEnd = false
                        )
                    )
                )
            )
        } returns (Unit)
        /***************************************************
         * when - 동작 실행
         ***************************************************/
        vm.viewState.value.title.value = title
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.viewState.value.amount.value = amount
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.viewState.value.enterPersonName.value = enterPersonName
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.setEvent(HomeWriteContract.Event.SaveEnterPersonClicked)
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        vm.setEvent(HomeWriteContract.Event.CompleteButtonClicked)
        coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

        // effect 의 마지막 값을 가져옵니다.
        var toast : HomeWriteContract.Effect? = null
        var navigation : HomeWriteContract.Effect? = null
        // 가져오고자 하는 flow
        vm.effect.take(2).collectIndexed { index, value ->
            if (index == 0) {
                toast = value
            } else {
                navigation = value
            }
        }
        // advanceUntilIdle 을 사용하면 대기중인 코루틴을 계속 기다리기 때문에 1초만 기다리기
        coroutineExtension.dispatcher.scheduler.advanceTimeBy(1000)


        /***************************************************
         * then - 결과 검증
         ***************************************************/
        assertEquals(true, vm.viewState.value.completeButtonEnabled.value)
        assertEquals(HomeWriteContract.Effect.Toast.ShowComplete, toast)
        assertEquals(HomeWriteContract.Effect.Navigation.GoToBack, navigation)
    }
}

 

코드는 크게 어려운 점이 없어서 가볍게 읽어보시는 것을 추천드립니다.

 

테스트 코드의 검증은 "추가하기 버튼 활성화 여부", "추가하기 기능의 정상 동작" 2가지 입니다.

 

2가지 테스트를 좀 더 효율적으로 관리하기 위해서 테스트 순서도 10, 20번대로 구분해서 작성하였습니다.

 

그 외 특이 사항은 다음과 같습니다.

// 이전 동작이 끝날때 까지 대기
coroutineExtension.dispatcher.scheduler.advanceUntilIdle()

// 1초간 대기
coroutineExtension.dispatcher.scheduler.advanceTimeBy(1000)

// mock
coEvery { ... }

// effect 가져오기
vm.effect.take(2).collectIndexed...

 

 

작성된 테스트 코드를 검증합니다.

 

 

작성한 테스트 코드가 모두 성공한 것을 확인하였습니다.

 

이번 테스트 코드에서는 간단하게 버튼 활성화, 데이터 추가 후 로직 검증 정도 였지만, 서비스가 고도화 되다보면 특정 회원인지 아닌지 등을 분류하여 특정 기능을 제공하고 문제 없는지 등을 검증하는 복잡한 테스트도 생길 수 있습니다.

 

테스트 코드를 작성하려는 목적이 무엇인지 정확하게 파악하고 정확하게 구현하여 코드 작성하게 되면 안정적인 서비스를 운영하는데 많은 도움이 될 것으로 생각합니다.

 

전체 코드는 다음과 같습니다.

 

GitHub - tmvlke/SimpleDutch: 심플더치 프로젝트 입니다.

심플더치 프로젝트 입니다. Contribute to tmvlke/SimpleDutch development by creating an account on GitHub.

github.com

 

감사합니다.

 

728x90
반응형

관련글 더보기