관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] ViewModel, LiveData, RxJava UnitTest 본문

안드로이드/Unit Test

[안드로이드] ViewModel, LiveData, RxJava UnitTest

막무가내막내 2020. 8. 16. 20:52
728x90

https://lagojin.github.io/livedata-test/

 

LiveData Testing 어떻게 하니?

Android에 존재하는 Livedata에 관한 테스트 방법입니다.

lagojin.github.io

 

https://medium.com/androiddevelopers/unit-testing-livedata-and-other-common-observability-problems-bb477262eb04

 

Unit-testing LiveData and other common observability problems

Next time you’re scratching your head looking at a perfectly fine unit test with LiveDatas that should be passing, or at an empty screen…

medium.com

 

LiveData 가 엮인 부분들을 Unit Test 할때 보면 좋은 자료들입니다.

저 또한 유닛테스트를 처음 해보면서 겪은 것들을 포스팅 해보겠습니다.


 

다음과 같이 VIewModel을 테스트한다면 예를들어 뷰모델에서 서버나 로컬 데이터소스에 데이터를 요청하는 함수를 호출해서 LiveData 에 값이 잘 세팅 되는지 확인하는 경우가 대부분일 것 입니다.

class ViewModel(private val repo : Repository) : ViewModel() {
	
	private val _itemList  = MutableLiveData<ArrayList<Item>>()
    
    val itemList = LiveData<ArrayList<Item>> get() = _itemList
    
    fun requestItems(){
    	 repo.requestItems()
    	    .subscribOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe({ items->
                //아이템리스트가 잘 받아지는지 유닛테스트가 해보고 싶다!
            	_itemList.value = items as ArrayList<Item>?
            }, {
            	//error
            })
    
    }


}

그러나 requestItems() 함수를 호출하고 LiveData를 유닛테스트하려면 requestItems() 안의 구현체들은 RxJava 의 비동기 스레드에서 실행되므로 LiveData가 함수 실행 후 잘 세팅되었는지 테스트를 할수가 없었습니다.

예를들어 

유닛테스트 코드에서 뷰모델의 requestItems() 호출 

그 밑에줄 코드에 itemLIst : LiveData 가 값이 잘왔는지 UnitTest(ex. Assert) 를 할 경우 requestItems()는 비동기로 실행되서 결과를 받기전에 이미 itemList를 검증하는 유닛코드 테스트가 실행되어 값이 세팅되있지 않은 라이브데이터를 유닛테스트하게 됩니다.

 

이를 해결하기 위해 구글링한결과 LiveData를 테스트할때 다음과 같은 라이브데이터 확장함수를 추가해서 사용하라고 말하는걸 볼 수 있었습니다.

이 함수는 해당 라이브데이터가 값을 갖고있다면 즉시 반환하고 새로운 값을 받을때까지 라이브데이터를 옵저빙하고 받으면 옵저빙을 해제합니다. (기본값으로는 2초동안 기다리며 만약 2초가 지나서 값이 세팅되지 않으면 exception 을 발생시킵니다. 시간은 생성자 매개변수로 조정할 수 있습니다.)

자세한 설명은 밑 사이트가면 볼 수 있습니다.

/* Copyright 2019 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */
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(o: T?) {
            data = o
            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
}

 

https://medium.com/androiddevelopers/unit-testing-livedata-and-other-common-observability-problems-bb477262eb04 

 

Unit-testing LiveData and other common observability problems

Next time you’re scratching your head looking at a perfectly fine unit test with LiveDatas that should be passing, or at an empty screen…

medium.com

 

 

또는 테스트 중에 LiveData를 계속 관찰해야하는 경우 확인하려는 여러 값을받을 수 있게 다음 확장함수를 사용하면 됩니다. 이 또한 위 사이트를 참고했습니다.

/**
 * Observes a [LiveData] until the `block` is done executing.
 */
fun <T> LiveData<T>.observeForTesting(block: () -> Unit) {
    val observer = Observer<T> { }
    try {
        observeForever(observer)
        block()
    } finally {
        removeObserver(observer)
    }
}

 

위 확장함수를 추가하고

viewModel.requestItems() 

Log.d("EXAMPLE", viewModel.itemList.getOrAwaitValue.toString()) 

를 통해 뷰모델에서 requestItems() 호출 후의 결과 값을 로그에서 확인할 수 있었습니다.

 

혹은 다음과 같은 방법도 있습니다.

viewModel.requestItems() 

viewModel.itemList.getOrAwaitValue.toObservable().subsucribe{}

 

 

 

실제로 적용해본 코드는 다음과 같습니다.

package com.mtjin.nomoneytrip.views.localpage

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.mtjin.nomoneytrip.data.community.Review
import com.mtjin.nomoneytrip.data.community.UserReview
import com.mtjin.nomoneytrip.data.home.Product
import com.mtjin.nomoneytrip.data.local_page.TourIntroduce
import com.mtjin.nomoneytrip.data.local_page.source.LocalPageRepository
import com.mtjin.nomoneytrip.data.login.User
import com.mtjin.nomoneytrip.views.getOrAwaitValue
import io.reactivex.Single
import org.junit.*
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.junit.MockitoRule

@RunWith(MockitoJUnitRunner::class)
class LocalPageViewModelTest {
    @get:Rule
    var rule: MockitoRule = MockitoJUnit.rule()

    @JvmField
    @Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    @Mock
    lateinit var repository: LocalPageRepository

    private lateinit var viewModel: LocalPageViewModel

    @Before
    fun setUp() {
        viewModel = LocalPageViewModel(repository)
    }

    @After
    fun tearDown() {
    }

    @Test
    fun request_reviews_then_user_review_list_should_set_if_it_null_or_empty() {
        val userReviews = ArrayList<UserReview>()
        viewModel.city = "city"
        Mockito.`when`(repository.requestReviews("city", 2))
            .thenReturn(Single.just(userReviews))
        viewModel.requestReviews()
        viewModel.userReviewList.getOrAwaitValue()
        Assert.assertNotNull(userReviews)
        Assert.assertEquals(viewModel.userReviewList.value?.size, 0)
        Assert.assertEquals(viewModel.userReviewList.value, userReviews)
    }

    @Test
    fun request_reviews_then_page_should_add_five() {
        val userReviews = ArrayList<UserReview>()
        viewModel.city = "city"
        val page = 2
        viewModel.page = page
        Mockito.`when`(repository.requestReviews("city", page))
            .thenReturn(Single.just(userReviews))
        viewModel.requestReviews()
        viewModel.userReviewList.getOrAwaitValue()
        Assert.assertEquals(viewModel.page, page + 5)
    }

    @Test
    fun request_reviews_then_user_review_list_should_not_set_if_last_review_id_is_same() {
        val userReviews = ArrayList<UserReview>()
        userReviews.add(UserReview(User(id = "1"), Review(id = "1"), Product()))
        userReviews.add(UserReview(User(id = "2"), Review(id = "2"), Product()))
        userReviews.add(UserReview(User(id = "3"), Review(id = "3"), Product()))
        userReviews.add(UserReview(User(id = "4"), Review(id = "4"), Product()))
        viewModel.city = "city"
        val page = 4
        viewModel.page = page
        Mockito.`when`(repository.requestReviews("city", page))
            .thenReturn(Single.just(userReviews))
        viewModel.requestReviews()
        viewModel.userReviewList.getOrAwaitValue()
        Assert.assertNotNull(userReviews)
        Assert.assertEquals(userReviews.size, viewModel.userReviewList.value?.size)
        Assert.assertEquals(userReviews, viewModel.userReviewList.value)
        Assert.assertEquals(userReviews.last().review, viewModel.lastUserReview.review)
        Assert.assertEquals(page + 5, viewModel.page)

        // 현재 리스트의 마지막데이터와 현재 준 데이터가 같다면 마지막 데이터이므로 리스트 데이터 갱신 X
        val prevReviewList = viewModel.userReviewList
        val prevLastUserReview = viewModel.lastUserReview
        Mockito.`when`(repository.requestReviews("city", viewModel.page))
            .thenReturn(Single.just(userReviews))
        viewModel.userReviewList.getOrAwaitValue()
        Assert.assertEquals(viewModel.userReviewList, prevReviewList)
        Assert.assertEquals(viewModel.lastUserReview, prevLastUserReview)
    }

    @Test
    fun request_tour_introduces_then_tour_introduce_list_should_set_if_success() {
        val areaCode = 1
        val tourList = ArrayList<TourIntroduce>()
        Mockito.`when`(repository.requestTourIntroduces(areaCode))
            .thenReturn(Single.just(tourList))
        viewModel.requestTourIntroduces(areaCode)
        viewModel.tourIntroduceList.getOrAwaitValue()
        Assert.assertEquals(tourList.size, viewModel.tourIntroduceList.value?.size)
    }

}

여기까지는 직접 추가생성한 LiveDate 확장함수 getOrAwaitValue()를 사용한 방법이었습니다.

 

 


 

만약 RxJava2 를 사용하면 자체적으로 라이브러리에서 제공하는 test()를 써도됩니다. 

이 방식은 TestObserver 클래스를 사용하는 방법으로 test()가 반환하는게 TestObserver 객체입니다.

이TestObserver의 다양한 함수를 사용하여 Observable이 방출하는 데이터를 테스트할 수 있습니다.

 

TestObserver 클래스의 주요함수는 다음과 사이트에서 정리를 잘 해놓으셨습니다.

beomseok95.tistory.com/235

 

TestObserver ,TestScheduler 를 이용한 테스트작성하기

TestObserver ,TestSubscriberTestScheduler 를 이용한 테스트작성하기 RxJava2부터 좀 더 간결해진 테스트 바로 TestObserver , TestSubscriber 를 이용한 테스트 작성법에 대하여 알아보도록 하겠습니다. Te..

beomseok95.tistory.com

 

다음은 저의 예시 코드 기록입니다.

 

[Local]

@RunWith(MockitoJUnitRunner::class)
class MyMeetingRoomLocalDataSourceImplTest {

    @get:Rule
    var rule: MockitoRule = MockitoJUnit.rule()

    // context
    private val appContext = InstrumentationRegistry.getInstrumentation().targetContext

    // 로컬
    private lateinit var dao: MyMeetingRoomDao
    private lateinit var localDataSource: MyMeetingRoomLocalDataSource

    private val myRoomList = ArrayList<MyMeetingRoom>()
    private val myRoomFake = MyMeetingRoom(id = 11111, timestamp = 1, roomName = "FAKE1")

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        dao = Room.databaseBuilder(
            appContext,
            RoomDatabase::class.java,
            "WorksRoom.db"
        ).build().myMeetingRoomDao()
        localDataSource = MyMeetingRoomLocalDataSourceImpl(dao)

        myRoomList.add(MyMeetingRoom(id = 11111, timestamp = 1, roomName = "FAKE1"))
        myRoomList.add(MyMeetingRoom(id = 22222, timestamp = 2, roomName = "FAKE2"))
        myRoomList.add(MyMeetingRoom(id = 33333, timestamp = 3, roomName = "FAKE3"))
    }

    @After
    fun tearDown() {
    }

    @Test //데이터가 로컬에 추가되었음을 확인
    fun insertMeetingRoom() {
        localDataSource.insertMeetingRoom(myRoomList)
            .test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertNoErrors()
                it.assertComplete()
                it.assertSubscribed()
            }
    }


    @Test // 현재시간 이후의 데이터만 가져온다.
    fun getMyMeetingRooms() {
        localDataSource.getMyMeetingRooms()
            .test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertComplete()
                it.assertSubscribed()
                it.assertNoErrors()
                Asserts.checkNotNull(it)
                if (it.valueCount() > 0) {
                    for (room in it.values()[0]) {
                        if (room.endTimestamp < getTimestamp()) {
                            Assert.fail("내 예약관리에서 현재보다 이전시간의 데이터를 로컬에서 불러오는 에러")
                        }
                    }
                }
            }
    }

    @Test // 추가된 데이터를 삭제할 수 있어야한다.
    fun deleteMyMeetingRoom() {
        localDataSource.deleteMyMeetingRoom(myRoomFake)
            .test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertSubscribed()
                it.assertNoErrors()
                Log.d(TAG, it.values().toString())
            }
    }
}

 

[Remote]

@RunWith(MockitoJUnitRunner::class)
class MyMeetingRoomRemoteDataSourceImplTest {

    @get:Rule
    var rule: MockitoRule = MockitoJUnit.rule()

    @get:Rule
    var activityRule = ActivityTestRule(MainActivity::class.java)

    // context
    private val appContext = InstrumentationRegistry.getInstrumentation().targetContext

    private val myRoomList = ArrayList<MyMeetingRoom>()

    private val remoteDataSource = MyMeetingRoomRemoteDataSourceImpl(Firebase.database.reference)

    @Mock
    private lateinit var remote: MyMeetingRoomRemoteDataSource

    @Before
    fun setUp() {
        Thread.sleep(5000)
        MockitoAnnotations.initMocks(this)
        Firebase.initialize(appContext)

        val timestamp = getTimestamp()
        myRoomList.add(
            MyMeetingRoom(
                id = 1,
                roomName = "room",
                endTimestamp = timestamp + 10000000
            )
        )
        myRoomList.add(
            MyMeetingRoom(
                id = 2,
                roomName = "room2",
                endTimestamp = timestamp + 10000000
            )
        )
        myRoomList.add(
            MyMeetingRoom(
                id = 3,
                roomName = "room3",
                endTimestamp = timestamp - 1000000
            )
        )
    }

    @After
    fun tearDown() {
    }

    @Test // 내가 예약한 회의실이 있는 경우 리스트 반환
    fun requestMyMeetingRooms() {
        `when`(remote.requestMyMeetingRooms()).thenReturn(Flowable.just(myRoomList))
        remote.requestMyMeetingRooms()
            .test()
            .awaitDone(3000, TimeUnit.MILLISECONDS).assertOf {
                it.assertSubscribed() // Subscribe 되었는지
                it.assertComplete() // Complete 되었는지
                Log.d(TAG, it.values().toString())
            }
    }

    @Test
    fun myMeetingRoomTimestampShouldAfterNow() {
        val now = getTimestamp()

        remoteDataSource.requestMyMeetingRooms().test().awaitDone(2000, TimeUnit.MILLISECONDS)
            .assertOf {
                for (myRoom in it.values()[0]) {
                    if (myRoom.endTimestamp < now) {
                        Assert.fail()
                    }
                }
            }

    }
}

 

[Repository]

@RunWith(MockitoJUnitRunner::class)
class MyMeetingRoomRepositoryImplTest {

    @get:Rule
    var rule: MockitoRule = MockitoJUnit.rule()

    @Mock
    lateinit var localDataSource: MyMeetingRoomLocalDataSource

    @Mock
    lateinit var remoteDataSource: MyMeetingRoomRemoteDataSource

    private lateinit var repository: MyMeetingRoomRepository

    private val localRoomList = ArrayList<MyMeetingRoom>()
    private val remoteRoomList = ArrayList<MyMeetingRoom>()
    private val removeRoom =
        MyMeetingRoom(id = 1, roomName = "room", endTimestamp = getTimestamp() + 10000000)

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        for (i in 1..2) {
            localRoomList.add(
                MyMeetingRoom(
                    id = i.toLong(),
                    roomName = "localRoom$i",
                    endTimestamp = getTimestamp()
                )
            )
        }
        for (i in 1..3) {
            remoteRoomList.add(
                MyMeetingRoom(
                    id = i.toLong(),
                    roomName = "remoteRoom$i",
                    endTimestamp = getTimestamp()
                )
            )
        }

        `when`(localDataSource.deleteMyMeetingRoom(removeRoom)).thenReturn(Completable.complete())
        `when`(localDataSource.insertMeetingRoom(localRoomList)).thenReturn(Completable.complete())
        `when`(localDataSource.getMyMeetingRooms()).thenReturn(Single.just(localRoomList))
        `when`(remoteDataSource.requestMyMeetingRooms()).thenReturn(Flowable.just(remoteRoomList))
        `when`(
            remoteDataSource.cancelReservation(removeRoom)
        ).thenReturn(Completable.complete())

        repository = MyMeetingRoomRepositoryImpl(
            remoteDataSource, localDataSource
        )
    }

    @After
    fun tearDown() {

    }

    @Test
    fun requestMyMeetingRooms() {
        repository.requestMyMeetingRooms().test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertSubscribed()
                it.assertNotComplete()
                Assert.assertNotNull(it) // Null 체크
                Log.d(TAG, it.values().toString())
                Assert.assertEquals(
                    localRoomList.size,
                    it.values()[0].count()
                ) // local 에서 전달한 개수 확인
            }
    }

    @Test
    fun requestRemoteMyMeetingRooms() {
        repository.requestRemoteMyMeetingRooms().test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertSubscribed()
                Assert.assertNotNull(it)
                Log.d(TAG, it.values().toString())
            }
    }

    @Test
    fun requestLocalMyMeetingRooms() {
        repository.requestLocalMyMeetingRooms().test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertSubscribed()
                it.assertComplete()
                Assert.assertEquals(localRoomList.size, it.values()[0].size)
            }
    }

    @Test
    fun cancelReservation() {
        repository.cancelReservation(removeRoom)
            .test()
            .awaitDone(3000, TimeUnit.MILLISECONDS)
            .assertOf {
                it.assertSubscribed()
                it.assertComplete()
            }
    }
}

 

[ViewModel]

@RunWith(MockitoJUnitRunner::class)
class MyMeetingRoomViewModelTest {
    @get:Rule
    var rule: MockitoRule = MockitoJUnit.rule()

    @JvmField
    @Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    // context
    private val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
    private lateinit var networkManager: NetworkManager
    private lateinit var viewModel: MyMeetingRoomViewModel

    @Mock
    lateinit var repository: MyMeetingRoomRepository

    private val roomList = ArrayList<MyMeetingRoom>()
    private val removeItem =
        MyMeetingRoom(id = 1, roomName = "room1", endTimestamp = getTimestamp())
    private val timestamp = getTimestamp()

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        networkManager = NetworkManager(appContext)

        for (i in 1..3) {
            roomList.add(
                MyMeetingRoom(
                    id = i.toLong(),
                    roomName = "room$i",
                    endTimestamp = timestamp
                )
            )
        }
        roomList.add(
            MyMeetingRoom(
                id = 999,
                roomName = "room999",
                endTimestamp = timestamp - 100000
            )
        )

        `when`(repository.requestMyMeetingRooms()).thenReturn(Flowable.just(roomList))
        `when`(repository.cancelReservation(removeItem)).thenReturn(Completable.complete())
        viewModel = MyMeetingRoomViewModel(repository, networkManager)
    }

    @After
    fun tearDown() {
    }

    @Test
    fun requestMyMeetingRoomsTimeShouldGreaterThanCurrentTime() {
        viewModel.requestMyMeetingRooms()
        Assert.assertEquals(viewModel.isLoading.value, true)
        viewModel.myMeetingRoomList.getOrAwaitValue().toObservable()
            .observeOn(Schedulers.io())
            .subscribe {
                assertEquals(viewModel.isLoading.value, false)
                assertNotNull(it)
                assertEquals(roomList, it)
                for (room in roomList) {
                    if (room.endTimestamp < timestamp) {
                        fail("현재시간 이후의 시간들만 보여야합니다.")
                    }
                }
                Log.d(TAG, roomList.toString())
                Log.d(TAG, it.toString())
            }
    }

    @Test
    fun cancelReservation() {
        Assert.assertEquals(viewModel.isLottieLoading.value, false)
        viewModel.cancelReservation(removeItem)
        Assert.assertEquals(viewModel.cancelResultMsg.getOrAwaitValue(), true)
    }
}

 

 

 

이번에 처음 공부하면서 급하게 남긴 포스팅이라 빠져있는 부분이나 미숙한점이 많습니다. ㅠㅠ

더 성장해서 더 정확한 테스트 코드 작성법에 대해 매력적인 포스팅을 하도록 하겠습니다. ㅎㅎ 

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!

728x90
Comments