나만의 안드로이드 앱 만들기(중급자 편) - 테스트 코드 작성하기 (viewModel)
이번 편에서는 뷰모델 의 테스트 코드를 작성해보도록 하겠습니다.
유틸 클래스의 테스트 코드 작성 방법이 궁금하시다면 아래 링크 참고 부탁드립니다.
나만의 안드로이드 앱 만들기(중급자 편) - 테스트 코드 작성하기 (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
테스트 목표은 다음과 같습니다.
- 더치 페이 내용을 추가하려 합니다.
- 지출 내용, 총 가격, 참여 인원 이름이 하나 이상 등록되어 있는 상태에서만 "추가하기" 버튼 클릭이 가능해야 합니다.
- 저장이 완료되었다면 완료에 대한 토스트가 노출되며, 이전 페이지로 이동되어야 합니다.
이를 구현하기 위한 과정은 다음과 같습니다.
- 현재 테스트 디렉터리가 구축되어 있지 않아 테스트 디렉터리 구조에 대한 구성을 진행합니다.
- 코루틴 사용이 가능한 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
감사합니다.
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/008.gif)