티스토리 뷰

안드로이드

ViewModel Test

pjm1n 2025. 7. 27. 01:47

📖  사용한 라이브러리

❓androidx.arch.core:core-testing

Android Architecture Components 테스트 도우미로 주로 LiveData나 ViewModel을 테스트할 때 사용한다.

❓io.mockk:mockk

테스트 대상 클래스의 의존성이나 함수의 동작을 흉내 내는 데 사용한다.

❓kotlinx-coroutines-test

코루틴 기반 코드를 테스트하기 위한 도구들을 제공하는 Kotlin 코루틴 테스트 전용 라이브러리이다.

💡 LiveData, ViewModel 관련 초기 세팅

✅ InstantTaskExecutorRule()

  • 테스트 환경에서는 메인쓰레드와 메인 루퍼(Looper)가 없어서 setValue()를 호출해도 메인 쓰레드 컨텍스트가 없으면 동작에 제한이 생긴다.
  • InstantTaskExecutorRule은 LiveData의 쓰레드 제약을 해제해서 setValue()를 호출해도 즉시 동기적으로 실행되도록 도와준다.
  • ViewModel이나 LiveData를 사용하는 로직을 테스트 환경(JVM 환경)에서도 손쉽게 테스트 할 수 있도록 지원해준다.
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()

요렇게 사용한다. public 키워드를 권장한다고한다. 사용하지 않는 이유를 알기 위해서는 우선 리플렉션에 대해 알아야한다.

✅ reflection

  • 프로그램이 실행중, 즉 런타임에 클래스, 함수, 변수 등의 정보를 조회하거나 조작하는 기능이다.

JUnit에서 @Rule이나 @get: Rule 로 지정된 프로퍼티는 리플렉션을 통해 찾는다. 이때 private/protected/internal 일 경우 리플렉션이 제대로 작동하지 않아서 JUnit이 rule을 무시하고 적용 못하는 문제가 생길 수 있다. 그렇게 되면 다음과 같은 오류가 발생한다.

@get:Rule
private val instantExecutorRule = InstantTaskExecutorRule() 
// IllegalStateException: No @Rule annotated fields found

✅ mockk()

  • mockk(): 기본 mock객체 생성
  • coEvery{}: suspend 함수 모킹
  • coVerify{}: suspend 함수 호출 검증

ViewModel에 주입한 Repository클래스를 모킹하기 위해 사용했다. setUp 함수에서 coEvery를 통해 모킹한 이유는 ScheduleViewModel의 init 블록에 fetchAllScheduleDates()와 fetchScheduleEventsById(dateId)를 호출하는 loadAllScheduleDates(),loadScheduleByDate()함수를 호출했기 때문에 ScheduleViewModel이 생성되기 전에 모킹을 해놓아야했다.

private lateinit var scheduleViewModel: ScheduleViewModel

private val testDispatcher = StandardTestDispatcher()

    private val dateId = 1L
    private lateinit var scheduleRepository: ScheduleRepository
    private lateinit var scheduleViewModel: ScheduleViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)

        scheduleRepository = mockk()

        coEvery { scheduleRepository.fetchAllScheduleDates() } returns
            Result.success(
                FAKE_SCHEDULE_DATES,
            )
        coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns
            Result.success(
                FAKE_SCHEDULE_EVENTS,
            )

        scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId)
    }
   

✅ 테스트에서 사용하는 setValue(), postValue()

테스트를 하기 위해서는 LiveData에 값을 세팅해줘야한다. 하지만 테스트에서는 setValue(), postValue()를 쓸 수 없다.

public abstract class MutableLiveData<T> {
    protected void setValue(T value)
    protected void postValue(T value)
}

MutableLiveData의 내부를 보면 setValue()와 postValue()는 protected로 선언되어있기 때문에 MutableLiveData를 상속 받은 클래스만 이 함수들을 사용할 수 있다. 하지만 테스트 코드에서는 LiveData타입이다. 그래서 나는 다음과 같이 리플렉션을 사용하여 setter 함수를 만들었다. gpt의 도움을 야무지게 받았다.

@Suppress("UNCHECKED_CAST")
inline fun <ITEM, reified VIEWMODEL> setUpTestLiveData(
    item: ITEM,
    fieldName: String,
    viewModel: VIEWMODEL,
) {
    val field = VIEWMODEL::class.java.getDeclaredField(fieldName)
    field.isAccessible = true
    val liveData = field.get(viewModel) as MutableLiveData<ITEM>
    liveData.postValue(item)
}

ViewModel에서 setter 함수를 직접 만들어서 사용해도 되지만 단지 테스트 하나만을 위해 ViewModel에서 setter 함수를 만들고 싶지 않았다.

아래는 LiveData의 getter함수이다.

fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer =
        object : Observer<T> {
            override fun onChanged(value: T) {
                data = value
                latch.countDown()
                this@getOrAwaitValue.removeObserver(this)
            }
        }

    this.observeForever(observer)

    // Don't wait indefinitely if the LiveData is not set.
    if (!latch.await(time, timeUnit)) {
        throw TimeoutException("LiveData value was never set.")
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

💡 Coroutine 관련 세팅

✅ StandardTestDispatcher()

  • 코루틴 테스트용 디스패처 중 하나로 launch{} 같은 비동기 실행을 시뮬레이션 할 수 있게 해준다.
  • 실제로는 작업을 즉시 실행시키지 않고 큐에 쌓았다가 advanceUntilIdle()를 통해 원하는 시점에 작업 실행이 가능하게 해준다.
 @Test
    fun `해당 날짜에 맞는 일정을 불러옫다`() =
        runTest {
            // given

            // when
            scheduleViewModel.loadScheduleByDate() //아직 코루틴 실행 x(예약만 함)
            advanceUntilIdle() //예약 작업을 강제로 실행
         }

✅ Dispatchers.setMain()

  • 코루틴을 쓰레드에서 작동시키고 싶은데 JVM Unit Test는 쓰레드가 존재하지 않는다. 그래서 `Dispatchers.setMain()`을 통해 쓰레드에서 작동시키는 척을 한다.

✅ Dispatchers.resetMain()

  • 테스트가 끝난 후 Dispatchers.setMain으로 덮어쓴 가짜 디스패처를 원래의 실제 디스패처로 되돌린다. 즉, 초기 상태로 돌아온다는 말이다.
@After
    fun tearDown() {
        Dispatchers.resetMain()
    }

✅ runTest {}

  • 코루틴 실행을 블로킹하지 않고, 테스트 함수 안에서 비동기 코드를 동기처럼 실행 가능하게 해준다.
  • 내부적으로 가상 시간을 사용하기 때문에 delay()나 타이머 같은 지연을 빠르게 신행시킨다.
  • TestScope라는 특별한 코루틴 스코프 내에서 실행되어, 테스트 종료시 미완료 코루틴이 있으면 실패로 처리해준다.
  • 디스패처를 별도로 설정하지 않아도 기본적으로 테스트 전용 디스패처(TestDispatcher)를 사용한다.

 

🧑‍💻 전체코드

@OptIn(ExperimentalCoroutinesApi::class)
class ScheduleViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private val testDispatcher = StandardTestDispatcher()

    private val dateId = 1L
    private lateinit var scheduleRepository: ScheduleRepository
    private lateinit var scheduleViewModel: ScheduleViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)

        scheduleRepository = mockk()

        coEvery { scheduleRepository.fetchAllScheduleDates() } returns
            Result.success(
                FAKE_SCHEDULE_DATES,
            )
        coEvery { scheduleRepository.fetchScheduleEventsById(dateId) } returns
            Result.success(
                FAKE_SCHEDULE_EVENTS,
            )

        scheduleViewModel = ScheduleViewModel(scheduleRepository, dateId)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `선택한 북마크의 상태가 변경된다`() =
        runTest {
            // given
            val id = 1L

            setUpTestLiveData(
                item = FAKE_SCHEDULE_EVENTS_UI_STATE,
                fieldName = "_scheduleEventsUiState",
                viewModel = scheduleViewModel,
            )

            // when
            scheduleViewModel.updateBookmark(id)

            // then
            coVerify { scheduleRepository.fetchAllScheduleDates() }
            coVerify { scheduleRepository.fetchScheduleEventsById(dateId) }

            val events =
                when (val result = scheduleViewModel.scheduleEventsUiState.getOrAwaitValue()) {
                    is ScheduleEventsUiState.Success -> result.events
                    else -> emptyList()
                }

            val expected = true
            val result = events.find { it.id == id }?.isBookmarked
            assertEquals(expected, result)
        }

    @Test
    fun `해당 날짜에 맞는 일정을 불러온다`() =
        runTest {
            // given

            // when
            scheduleViewModel.loadScheduleByDate()
            advanceUntilIdle()

            // then
            coVerify { scheduleRepository.fetchAllScheduleDates() }
            coVerify { scheduleRepository.fetchScheduleEventsById(dateId) }

            val state = scheduleViewModel.scheduleEventsUiState.value
            assertTrue(state is ScheduleEventsUiState.Success)

            val expected = FAKE_SCHEDULE_EVENTS.map { it.toUiModel() }
            val result = (state as ScheduleEventsUiState.Success).events
            assertEquals(expected, result)
        }
}

'안드로이드' 카테고리의 다른 글

LifeCycle Of ViewModel  (1) 2025.10.05
Android Thread 통신과 Handler·Looper  (4) 2025.08.29
DataBinding, BindingAdapter를 사용할 때 무한루프  (2) 2025.08.02
RecyclerView와 Adapter  (0) 2025.04.28
안드로이드 컴포넌트  (0) 2024.07.29
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함