0. 소개


안녕하세요.

안드로이드를 담당하고 있는 다나입니다.🙋🏻‍♀️

코드에 대한 신뢰와 확신을 얻기 위해 안드로이드 팀에서는 테스트 코드를 작성하기로 했습니다.

1. 왜?


테스트 코드를 작성해야 하는 이유는 여러 가지 존재하지만, 저희는 다음과 같은 이유로 정의했습니다.

2. 어디까지 작성해야 할까?


사용자와 상호작용을 하는 UI를 검증하는 테스트 코드를 작성해야 할지, API 응답 값에 대한 성공 및 실패를 검증하는 테스트 코드를 작성해야 할지에 대한 고민이 있었습니다.

결론부터 말씀드리면, 먼저 ViewModel만 작성하기로 했습니다.

3. 준비


테스트 코드 작성 이유와 범위를 정한 저희 팀은 실제로 작성을 위한 준비 작업에 들어갔습니다.

우선, 각자 JUnit5와 MockK의 개념에 대해 스터디하는 시간을 가졌습니다. 그 후 이해한 개념을 바탕으로 간단한 ViewModel을 정했습니다. 정한 ViewModel의 테스트 코드를 작성 후 서로 비교해보며 테스트 코드에 대한 정의를 내려가기로 했습니다.

JUnit5

테스트 코드는 개발자가 어떠한 생각을 가지고 구현했는지를 나타낼 수 있다고 생각합니다.
일종의 문서로서 가독성을 생각해, 다음과 같은 개념을 바탕으로 JUnit5을 선택했습니다. (@Nested, **@**DisplayName 으로 Given-When-Then 표현하기가 유용하다는 점이 JUnit5 선택에 가장 큰 이유입니다.)

JUnit4가 단일 모듈이었다면, JUnit5은 JUnit PlatformJUnit Jupiter + JUnit Vintage 세개의 모듈로 이루어져있습니다.

자주 사용하는 Annotation은 다음과 같습니다.

MockK

테스트 코드를 작성하기 위한 Kotlin용 모의 라이브러리입니다.

MockK는 JUnit5와 함께 사용시 @ExtendWith, @Mockk 을 사용해 쉽게 의존성을 주입할 수 있고, 주로 많이 사용하는 Mockito는 Kotlin DSL을 활용할 수 없어 저희는 좀 더 Kotlin스럽게 개발하기 위해 MockK를 선택했습니다. (Mockito와 큰 차이가 없어 러닝커브 없이 사용할 수 있습니다.)

공비서 앱에서 주로 사용하는 것으로는 다음과 같습니다.

LiveData Test

ViewModel에서 LiveData 변경에 대한 테스트 입니다.

// 동기적으로 실행되도록 Extension 정의
@ExtendWith(InstantTaskExecutorExtension::class)

Coroutines Test

ViewModel에서 비동기(Api호출)에 대한 테스트 입니다.

// 코루틴 Dispatcher 설정을 위한 Extension 정의
companion object {
		@JvmField
		@RegisterExtension
		val croutineExtension = MainCoroutineExtension()
}

4. 구현


예를 들어 시술메뉴 - 그룹 추가 ViewModel 테스트 코드 일부분을 작성해보겠습니다.

  1. LiveData, Coroutine을 테스트할 수 있도록 Extension을 정의합니다.

    @ExtendWith(MockKExtension::class)
    @ExtendWith(InstantTaskExecutorExtension::class)
    @ExperimentalCoroutinesApi
    internal class CosmeticMenuGroupViewModelTest {
        companion object {
            @JvmField
            @RegisterExtension
            val coroutineExtension = MainCoroutineExtension()
        }
    }
    
  2. 테스트를 위해 mock 객체를 생성합니다.

    internal class CosmeticMenuGroupViewModelTest {
        ...
        private val mApplicationMock = mockk<Application>(relaxed = true)
        private lateinit var viewModel: CosmeticMenuGroupViewModel
    
        @MockK
        private lateinit var cosmeticRepository: CosmeticRepository
    
    		@InjectMockKs
        lateinit var loadingState: LoadingState
    		...
    }
    
  3. @Test 메소드를 실행하기 전 공통적으로 구현해야 하는 부분을 @BeforEach를 활용해 구현합니다.

    @BeforeEach
        fun setUp() {
            mockkObject(Preferences)
            mApplicationMock.initializePreference()
            viewModel =
                spyk(
                    CosmeticMenuGroupViewModel(cosmeticRepository),
                    recordPrivateCalls = true
                ).apply {
                    loadingState = [email protected]
                }
            viewModel.name.addOnPropertyChangedCallback(viewModel.CosmeticMenuGroupNewChangeCallback())
        }
    
  4. UI 테스트 대안으로, EditText 입력 상태에 대한 Button 활성화 여부 테스트 코드는 다음과 같이 작성할 수 있습니다.

    @Nested
        @DisplayName("저장버튼은")
        inner class ClickedSaveButton {
            @Test
            @DisplayName("그룹 이름을 입력하지 않으면, isSaveEnabled 값을 false로 변경한다.")
            fun doNotEnterCosmeticGroupName() {
                viewModel.name.set("")
    
                assertFalse(viewModel.saveBtnEnable.value)
            }
    
            @Test
            @DisplayName("그룹 이름을 입력하면, isSaveEnabled 값을 true로 변경한다.")
            fun enterCosmeticGroupName() {
                val isSaveBtnEnable = viewModel.saveBtnEnable.value.default()
    
                viewModel.name.set("손")
    
    						verify { viewModel.updateIsSaveBtnEnable() }
                verify { viewModel["checkValueChanged"]() }
                assertEquals(isSaveBtnEnable.not(), viewModel.saveBtnEnable.value)
            }
        }
    
  5. 저장 api 호출의 성공 및 실패에 관한 테스트 코드는 다음과 같이 작성할 수 있습니다.

    @Nested
        @Deprecated("createCosmeticGroups()")
        inner class CalledCreateCosmeticGroups {
            @Test
            @DisplayName("중복되지 않는 그룹 이름을 입력하면, state 값을 CreateGroupSuccess으로 변경한다.")
            fun enterCosmeticGroupNameSuccess() = runTest {
    						val category = slot<String>()
                coEvery {
                    cosmeticRepository.createCosmeticGroups(any(), any(), capture(category), any())
                } returns NetworkResponse.Success(ResponseBase())
    
                viewModel.run {
                    name.set("발")
                    createCosmeticGroups("네일")
                }
    
                coroutineExtension.scheduler.advanceUntilIdle()
    
                assertEquals("네일", category.captured)
                assertEquals(CosmeticMenuGroupState.CreateGroupSuccess, viewModel.state.value)
            }
    
            @Test
            @DisplayName("중복되는 그룹 이름을 입력하면, transferError을 호출한다.")
            fun enterDuplicateCosmeticGroupName() = runTest {
                coEvery {
                    cosmeticRepository.createCosmeticGroups(any(), any(), any(), any())
                } returns NetworkResponse.ApiError(CosmeticTestData.createCosmeticGroupsDuplicate, 400)
    
                viewModel.name.set("발")
                viewModel.createCosmeticGroups("네일")
    
                coroutineExtension.scheduler.advanceUntilIdle()
    
                verify { viewModel["transferError"](ERROR_MESSAGE_GROUP_DUPLICATE) }
            }
    
            @Test
            @DisplayName("기타 오류가 발생하면, state 값을 NetworkFailToast으로 변경한다.")
            fun createCosmeticGroupsEctError() = runTest {
                coEvery {
                    cosmeticRepository.createCosmeticGroups(any(), any(), any(), any())
                } returns NetworkResponse.ApiError(
                    CosmeticTestData.apiError,  401
                ) andThen NetworkResponse.NetworkError(IOException())
    
                viewModel.name.set("발")
                viewModel.createCosmeticGroups("네일")
    
                coroutineExtension.scheduler.advanceUntilIdle()
    
                assertEquals(CosmeticMenuGroupState.NetworkFailToast, viewModel.state.value)
                assertEquals(CosmeticMenuGroupState.NetworkFailToast, viewModel.state.value)
            }
        }
    

5. 문제점 발견


테스트 코드 작성하면서 기존의 코드의 문제점도 발견해 다음과 같이 해결하고자 했습니다.

6. 끝!


이번 테스트 코드 작성으로, 막연히 테스트 코드를 작성해야 한다는 생각을 팀원들과 스터디 및 적용을 통해 정리할 수 있었습니다. (감사합니다! 체리님🍒)

리팩토링 및 새로운 코드를 작성할 때 테스트에 대한 두려움이 있었는데, 테스트 코드 작성으로 조금이나마 덜어낼 수 있어 좋았고 커버리지가 점점 빨간색에서 초록색으로 변하는 모습을 보면서 뿌듯하기도 했습니다.

테스트 코드 작성 전에 끊임없이 레거시를 제거하고, 프로젝트를 구조화하려고 노력했습니다. 이 과정이 있어 테스트 코드 작성에 다가갈 수 있었던 것 같습니다.

아직은 ViewModel뿐이지만, 모든 코드에 대한 테스트 코드 작성을 목표로 삼고 있습니다.

끝으로, 위와 같이 팀원들과 함께 테스트 코드 및 리팩토링을 통해 더욱 안정적인 공비서가 되기를 바라면서 이만 마칩니다!!!

감사합니다 😊


저희와 함께 하고 싶은 분들은 헤렌의 채용 담당자 에게 커피챗을 요청해 보세요! 헤렌은 현재 다양한 개발 직군을 적극적으로 채용하고 있습니다 🚀

https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https://www.notion.so/0909/JUnit5-MockK-Android-ViewModel-Unit-Test-code-7893ab0a658947d7ba9a510d84029a1c