일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 막내의 막무가내 알고리즘
- 막내의막무가내 플러터
- 막내의막무가내 알고리즘
- 막내의막무가내 코틀린 안드로이드
- 주택가 잠실새내
- 프로그래머스 알고리즘
- 막내의막무가내 프로그래밍
- 막무가내
- 2022년 6월 일상
- 막내의 막무가내
- 막내의막무가내 안드로이드
- 부스트코스
- flutter network call
- 막내의막무가내
- 막내의막무가내 코틀린
- 막내의막무가내 목표 및 회고
- 프래그먼트
- 주엽역 생활맥주
- 막내의막무가내 SQL
- 부스트코스에이스
- 막내의막무가내 플러터 flutter
- Fragment
- 막내의막무가내 일상
- 안드로이드
- 막내의막무가내 rxjava
- 막내의막무가내 코볼 COBOL
- 막내의막무가내 안드로이드 에러 해결
- 막내의막무가내 안드로이드 코틀린
- 안드로이드 sunflower
- 안드로이드 Sunflower 스터디
- Today
- Total
막내의 막무가내 프로그래밍 & 일상
[안드로이드] ViewModel, LiveData, RxJava UnitTest 본문
https://lagojin.github.io/livedata-test/
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
}
또는 테스트 중에 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 클래스의 주요함수는 다음과 사이트에서 정리를 잘 해놓으셨습니다.
다음은 저의 예시 코드 기록입니다.
[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)
}
}
이번에 처음 공부하면서 급하게 남긴 포스팅이라 빠져있는 부분이나 미숙한점이 많습니다. ㅠㅠ
더 성장해서 더 정확한 테스트 코드 작성법에 대해 매력적인 포스팅을 하도록 하겠습니다. ㅎㅎ
댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!
'안드로이드 > Unit Test' 카테고리의 다른 글
[안드로이드] Android UnitTest 정리 (9) | 2021.02.17 |
---|---|
[코틀린] 안드로이드 UI Unit Test with Espresso (2) | 2020.09.04 |
[안드로이드] Android Unit Test Coverage with Jacoco 안드로이드 통합 테스트 하는 방법 (Kotlin) (0) | 2020.08.23 |