About HERREN
home
Media
home

JUnit5 + MockK를 활용한 Android ViewModel Unit Test code 작성하기

0. 소개

안녕하세요.
안드로이드를 담당하고 있는 다나입니다.
코드에 대한 신뢰와 확신을 얻기 위해 안드로이드 팀에서는 테스트 코드를 작성하기로 했습니다.

1. 왜?

테스트 코드를 작성해야 하는 이유는 여러 가지 존재하지만, 저희는 다음과 같은 이유로 정의했습니다.
개발 도중 문제점을 미리 발견할 수 있습니다.
디바이스에 직접 빌드하여 테스트할 경우에 비해, 시간 및 휴먼리소스 절약이 가능합니다.
리팩토링 시 안정적으로 작업할 수 있습니다.
좋은 코드 및 구조로 구현 할 수 있습니다, → 테스트 코드를 작성하기 쉽도록, 테스트가 가능한 구조를 생각하며 코드를 구현합니다.

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

사용자와 상호작용을 하는 UI를 검증하는 테스트 코드를 작성해야 할지, API 응답 값에 대한 성공 및 실패를 검증하는 테스트 코드를 작성해야 할지에 대한 고민이 있었습니다.
결론부터 말씀드리면, 먼저 ViewModel만 작성하기로 했습니다.
안드로이드 UI Test를 하기에는 다른 테스트 코드보다 어렵고 번거롭다고 판단
안드로이드 공비서 앱은 MVVM구조로 UI를 ViewModel에서 state로 관리하고 있어 ViewModel 하나로 어느 정도 UI테스트가 가능하다고 판단
대부분의 로직은 ViewModel이 가지고 있어 충분히 테스트 가능하다고 판단
프로젝트 일정 상 모든 항목에 대해 테스트 코드 작성은 힘들다고 판단

3. 준비

테스트 코드 작성 이유와 범위를 정한 저희 팀은 실제로 작성을 위한 준비 작업에 들어갔습니다.
우선, 각자 JUnit5와 MockK의 개념에 대해 스터디하는 시간을 가졌습니다. 그 후 이해한 개념을 바탕으로 간단한 ViewModel을 정했습니다. 정한 ViewModel의 테스트 코드를 작성 후 서로 비교해보며 테스트 코드에 대한 정의를 내려가기로 했습니다.

JUnit5

테스트 코드는 개발자가 어떠한 생각을 가지고 구현했는지를 나타낼 수 있다고 생각합니다. 일종의 문서로서 가독성을 생각해, 다음과 같은 개념을 바탕으로 JUnit5을 선택했습니다. (@Nested, @DisplayName 으로 Given-When-Then 표현하기가 유용하다는 점이 JUnit5 선택에 가장 큰 이유입니다.)
JUnit4가 단일 모듈이었다면, JUnit5은 JUnit PlatformJUnit Jupiter + JUnit Vintage 세개의 모듈로 이루어져있습니다.
JUnit Platform : TestEngine 인터페이스 : TestEngine을 통해서 테스트를 발견하고, 실행하고, 결과를 보고
JUnit Jupiter : TestEngine 구현체 (JUnit5에서 제공) : 테스트 코드 작성에 필요한 junit-jupiter-api 모듈과 테스트 실행을 위한 junit-jupiter-engine 모듈이 존재
JUnit Vintage : TestEngine 구현체 (기존 JUnit4에서 제공)
자주 사용하는 Annotation은 다음과 같습니다.
@Nested : Nested Test Class를 작성할 때 사용 : Inner Class이어야만 함
@DisplayName : 테스트 클래스나 테스트 메소드에 이름을 붙여줄 때 사용
@BeforeEach / @AfterEach : 모든 테스트 실행 전/후에 테스트하기 위해 사용 : JUnit4 → @Before / @After
@BeforeAll / @AfterAll : 현재 클래스에서 가장 먼저/나중에 테스트하기 위해 사용 (static) : JUnit4 → @BeforeClass / @AfterClass
@Test : 테스트 메소드임을 선언 할 때 사용 : JUnit4 → @Test
@Disabled : class나 테스트를 사용하지 않음을 표시할 때 사용 : JUnit4 → @Ignore

MockK

테스트 코드를 작성하기 위한 Kotlin용 모의 라이브러리입니다.
MockK는 JUnit5와 함께 사용시 @ExtendWith, @Mockk 을 사용해 쉽게 의존성을 주입할 수 있고, 주로 많이 사용하는 Mockito는 Kotlin DSL을 활용할 수 없어 저희는 좀 더 Kotlin스럽게 개발하기 위해 MockK를 선택했습니다. (Mockito와 큰 차이가 없어 러닝커브 없이 사용할 수 있습니다.)
공비서 앱에서 주로 사용하는 것으로는 다음과 같습니다.
@MockK mock 객체 생성 (Repository를 주입)
@MockK private lateinit var repository: CosmeticRepository
Kotlin
복사
@InjectMockKs lateinit var 삽입
@InjectMockKs lateinit var loadingState: LoadingState
Kotlin
복사
spyk 실제 객체의 코드를 사용하지만 coEvery() / every()를 이용하여 stub 메소드는 정해진 응답만 반환하도록 하는 객체
// recordPrivateCalls: private 메소드를 사용 선언 viewModel = spyk(CosmeticMenuGroupViewModel(repository), recordPrivateCalls = true)
Kotlin
복사
coEvery() / every() mock 객체의 동작 정의
// returns // 특정한 값을 리턴 every { viewModel.state.value } returns ShopOperationSettingCloseDayState.NONE // just Runs // 실행 every { viewModel.updateIsSaveBtnEnable() } just Runs // coEvery -> 코루틴 실행 // throws // Exception 나게 함 coEvery { cosmeticRepository.createCosmeticGroups(any(), any(), any(), any()) } throws Exception()
Kotlin
복사
coVerify() / verify() 호출 여부 검증
verify { viewModel.hideLoading() } //coVerify -> 코루틴 실행 coVerify { viewModel.createCosmeticGroups("네일") }
Kotlin
복사
any() mock 객체 메서드의 파라미터 설정 (임의의 인자 값이 일치하도록 설정)
coEvery { cosmeticRepository.createCosmeticGroups(any(), any(), any(), any()) } returns NetworkResponse.Success(CosmeticTestData.createResponseSuccess)
Kotlin
복사
slot()capture() 인자 값이 재대로 넘어 갔는지 확인
val category = slot<String>() val groupId = slot<Int>() coEvery { repository.getCosmeticGroups(any(), any(), capture(category), capture(groupId)) } returns NetworkResponse.Success(response) viewModel.getCosmeticGroups("네일", 1) assertEquals("네일", category.captured) assertEquals(1, groupId.captured)
Kotlin
복사

LiveData Test

ViewModel에서 LiveData 변경에 대한 테스트 입니다.
// 동기적으로 실행되도록 Extension 정의 @ExtendWith(InstantTaskExecutorExtension::class)
Kotlin
복사

Coroutines Test

ViewModel에서 비동기(Api호출)에 대한 테스트 입니다.
// 코루틴 Dispatcher 설정을 위한 Extension 정의 companion object { @JvmField @RegisterExtension val croutineExtension = MainCoroutineExtension() }
Kotlin
복사

4. 구현

예를 들어 시술메뉴 - 그룹 추가 ViewModel 테스트 코드 일부분을 작성해보겠습니다.
1.
LiveData, Coroutine을 테스트할 수 있도록 Extension을 정의합니다.
@ExtendWith(MockKExtension::class) @ExtendWith(InstantTaskExecutorExtension::class) @ExperimentalCoroutinesApi internal class CosmeticMenuGroupViewModelTest { companion object { @JvmField @RegisterExtension val coroutineExtension = MainCoroutineExtension() } }
Kotlin
복사
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 ... }
Kotlin
복사
3.
@Test 메소드를 실행하기 전 공통적으로 구현해야 하는 부분을 @BeforEach를 활용해 구현합니다.
@BeforeEach fun setUp() { mockkObject(Preferences) mApplicationMock.initializePreference() viewModel = spyk( CosmeticMenuGroupViewModel(cosmeticRepository), recordPrivateCalls = true ).apply { loadingState = this@CosmeticMenuGroupViewModelTest.loadingState } viewModel.name.addOnPropertyChangedCallback(viewModel.CosmeticMenuGroupNewChangeCallback()) }
Kotlin
복사
4.
UI 테스트 대안으로, EditText 입력 상태에 대한 Button 활성화 여부 테스트 코드는 다음과 같이 작성할 수 있습니다.
시술메뉴 - 그룹 추가 화면에는 그룹 이름을 입력하는 EditText와 저장 Button이 존재
EditText의 입력값은 viewModel.name과 바인딩
저장 Button의 활성화 여부는 viewModel.isSaveEnabled와 바인딩
@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) } }
Kotlin
복사
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) } }
Kotlin
복사

5. 문제점 발견

테스트 코드 작성하면서 기존의 코드의 문제점도 발견해 다음과 같이 해결하고자 했습니다.
테스트 코드를 작성해보니 하나의 메소드에 여러 기능들이 복합적으로 엮어 있다. (테스트 코드 작성이 매우 곤란했던 기억이…) → 최대한 하나의 메소드는 하나의 기능만 하도록 구현
View에 View와 로직이 함께 있는 경우에는 테스트 코드를 작성할 수 없다. → View에 있는 로직을 ViewModel에 분리하는 리팩토링을하여, ViewModel 에서 분리된 로직의 테스트 코드를 작성
테스트 코드가 없는 기존 ViewModel의 작성은 언제해야하는 것인가. → 해당 ViewModel에 변경되는 부분이 있을 경우 그 부분만 우선 작성 → 1. 과 2. 에 대한 문제가 있을 경우 리팩토링

6. 끝!

이번 테스트 코드 작성으로, 막연히 테스트 코드를 작성해야 한다는 생각을 팀원들과 스터디 및 적용을 통해 정리할 수 있었습니다. (감사합니다! 체리님)
리팩토링 및 새로운 코드를 작성할 때 테스트에 대한 두려움이 있었는데, 테스트 코드 작성으로 조금이나마 덜어낼 수 있어 좋았고 커버리지가 점점 빨간색에서 초록색으로 변하는 모습을 보면서 뿌듯하기도 했습니다.
테스트 코드 작성 전에 끊임없이 레거시를 제거하고, 프로젝트를 구조화하려고 노력했습니다. 이 과정이 있어 테스트 코드 작성에 다가갈 수 있었던 것 같습니다.
아직은 ViewModel뿐이지만, 모든 코드에 대한 테스트 코드 작성을 목표로 삼고 있습니다.
끝으로, 위와 같이 팀원들과 함께 테스트 코드 및 리팩토링을 통해 더욱 안정적인 공비서가 되기를 바라면서 이만 마칩니다!!!
감사합니다
저희와 함께 하고 싶은 분들은 헤렌의 채용 담당자 에게 커피챗을 요청해 보세요! 헤렌은 현재 다양한 개발 직군을 적극적으로 채용하고 있습니다