이번 편에서는 뷰모델 의 테스트 코드를 작성해보도록 하겠습니다.
유틸 클래스의 테스트 코드 작성 방법이 궁금하시다면 아래 링크 참고 부탁드립니다.
나만의 안드로이드 앱 만들기(중급자 편) - 테스트 코드 작성하기 (utils)
테스트 코드는 간단하다면 간단하다고 느낄 수도 어렵다면 어렵다고도 느낄 수 있다고 생각합니다. 왜냐하면 어느 범위까지 작성해야 할지 고민이 많이 되기 때문입니다. 이번 편에서는 유틸
victorywskim.tistory.com
뷰모델의 테스트 코드는 유틸 클래스의 테스트 코드 방법과 조금 다릅니다.
유념해야 할 사항 몇가지가 있습니다.
다음 코드에서 HomeWriteViewModel 를 이용해서 테스트 코드를 작성해보도록 하겠습니다.
GitHub - tmvlke/SimpleDutch: 심플더치 프로젝트 입니다.
심플더치 프로젝트 입니다. Contribute to tmvlke/SimpleDutch development by creating an account on GitHub.
github.com
테스트 목표은 다음과 같습니다.
이를 구현하기 위한 과정은 다음과 같습니다.
테스트 디렉터리 구조에 대한 구성을 진행합니다.
테스트 로직은 presentation 모듈 내에 작성합니다.
코루틴 사용이 가능한 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()
}
}
뷰모델 테스트와 같이 코루틴을 사용하는 테스트들을 위한 공통된 설정을 제공합니다.
그 외에는 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 입니다.
이 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
감사합니다.
나만의 안드로이드 앱 만들기(중급자 편) - Compose 의 Closure 와 Recomposition(클로저, 리컴포지션) (1) | 2023.12.27 |
---|---|
나만의 안드로이드 앱 만들기(중급자 편) - 테스트 코드 작성하기 (utils) (0) | 2023.12.24 |
나만의 안드로이드 앱 만들기(중급자 편) - 클린 아키텍쳐 구현(clean architecture) (2) | 2023.12.24 |
나만의 안드로이드 앱 만들기(중급자 편) - 클린 아키텍쳐 개요(clean architecture) (1) | 2023.12.23 |
나만의 안드로이드 앱 만들기(중급자 편) - BuildConfig의 deprecated 대응하기 (4) | 2023.12.22 |