안녕하세요.
안드로이드를 담당하고 있는 다나입니다.🙋🏻♀️
코드에 대한 신뢰와 확신을 얻기 위해 안드로이드 팀에서는 테스트 코드를 작성하기로 했습니다.
테스트 코드를 작성해야 하는 이유는 여러 가지 존재하지만, 저희는 다음과 같은 이유로 정의했습니다.
사용자와 상호작용을 하는 UI를 검증하는 테스트 코드를 작성해야 할지, API 응답 값에 대한 성공 및 실패를 검증하는 테스트 코드를 작성해야 할지에 대한 고민이 있었습니다.
결론부터 말씀드리면, 먼저 ViewModel
만 작성하기로 했습니다.
테스트 코드 작성 이유와 범위를 정한 저희 팀은 실제로 작성을 위한 준비 작업에 들어갔습니다.
우선, 각자 JUnit5와 MockK의 개념에 대해 스터디하는 시간을 가졌습니다. 그 후 이해한 개념을 바탕으로 간단한 ViewModel을 정했습니다. 정한 ViewModel의 테스트 코드를 작성 후 서로 비교해보며 테스트 코드에 대한 정의를 내려가기로 했습니다.
테스트 코드는 개발자가 어떠한 생각을 가지고 구현했는지를 나타낼 수 있다고 생각합니다.
일종의 문서로서 가독성을 생각해, 다음과 같은 개념을 바탕으로 JUnit5을 선택했습니다.
(@Nested
, **@**DisplayName
으로 Given-When-Then 표현하기가 유용하다는 점이 JUnit5 선택에 가장 큰 이유입니다.)
JUnit4가 단일 모듈이었다면,
JUnit5은 JUnit Platform
+ JUnit Jupiter
+ JUnit Vintage
세개의 모듈로 이루어져있습니다.
자주 사용하는 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테스트 코드를 작성하기 위한 Kotlin용 모의 라이브러리입니다.
MockK는 JUnit5와 함께 사용시 @ExtendWith
, @Mockk
을 사용해 쉽게 의존성을 주입할 수 있고,
주로 많이 사용하는 Mockito는 Kotlin DSL을 활용할 수 없어 저희는 좀 더 Kotlin스럽게 개발하기 위해 MockK를 선택했습니다. (Mockito와 큰 차이가 없어 러닝커브 없이 사용할 수 있습니다.)
공비서 앱에서 주로 사용하는 것으로는 다음과 같습니다.
@MockK
mock 객체 생성 (Repository를 주입)
@MockK
private lateinit var repository: CosmeticRepository
@InjectMockKs
lateinit var 삽입
@InjectMockKs
lateinit var loadingState: LoadingState
*spyk
* 실제 객체의 코드를 사용하지만 coEvery() / every()를 이용하여 stub 메소드는 정해진 응답만 반환하도록 하는 객체
// recordPrivateCalls: private 메소드를 사용 선언
viewModel = spyk(CosmeticMenuGroupViewModel(repository), recordPrivateCalls = true)
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()
coVerify()
/ verify()
호출 여부 검증
verify { viewModel.hideLoading() }
//coVerify -> 코루틴 실행
coVerify { viewModel.createCosmeticGroups("네일") }
any()
mock 객체 메서드의 파라미터 설정 (임의의 인자 값이 일치하도록 설정)
coEvery {
cosmeticRepository.createCosmeticGroups(any(), any(), any(), any())
} returns NetworkResponse.Success(CosmeticTestData.createResponseSuccess)
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)
ViewModel에서 LiveData 변경에 대한 테스트 입니다.
// 동기적으로 실행되도록 Extension 정의
@ExtendWith(InstantTaskExecutorExtension::class)
ViewModel에서 비동기(Api호출)에 대한 테스트 입니다.
// 코루틴 Dispatcher 설정을 위한 Extension 정의
companion object {
@JvmField
@RegisterExtension
val croutineExtension = MainCoroutineExtension()
}
예를 들어 시술메뉴 - 그룹 추가 ViewModel 테스트 코드 일부분을 작성해보겠습니다.
LiveData, Coroutine을 테스트할 수 있도록 Extension을 정의합니다.
@ExtendWith(MockKExtension::class)
@ExtendWith(InstantTaskExecutorExtension::class)
@ExperimentalCoroutinesApi
internal class CosmeticMenuGroupViewModelTest {
companion object {
@JvmField
@RegisterExtension
val coroutineExtension = MainCoroutineExtension()
}
}
테스트를 위해 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
...
}
각 @Test 메소드를 실행하기 전 공통적으로 구현해야 하는 부분을 @BeforEach를 활용해 구현합니다.
@BeforeEach
fun setUp() {
mockkObject(Preferences)
mApplicationMock.initializePreference()
viewModel =
spyk(
CosmeticMenuGroupViewModel(cosmeticRepository),
recordPrivateCalls = true
).apply {
loadingState = [email protected]
}
viewModel.name.addOnPropertyChangedCallback(viewModel.CosmeticMenuGroupNewChangeCallback())
}
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)
}
}
저장 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)
}
}