오늘은 RxKotlin UnitTest 에 대해 알아 보자.

5가지 케이스로 나누었다. 다양한 케이스를 통해 어떤식으로 테스트를 진행하는지 알아보자.

1.countDownLatch

val latch = CountDownLatch(1)
service.getPosts()
   .subscribeOn(Schedulers.io())
   .observeOn(Schedulers.newThread()) //newThread 로 생성 했기 때문에 latch.await 을 사용 하지 않으면 onSuccess 타지 않고 바로 종료 됨.
   .subscribe(
       object : SingleObserver<List<Post>> {
           override fun onSuccess(t: List<Post>) {
               assert(t == posts)
               latch.countDown()
           }

           override fun onSubscribe(d: Disposable) {
               compositeDisposable.add(d)
           }

           override fun onError(e: Throwable) {
               latch.countDown()
           }
       }
   )

latch.await(3, TimeUnit.SECONDS) //CountDownLatch count 는 1 로 셋팅 되어서 countDown 이 한번 불리면 종료

2.blocking

val post = Post("포스트", false)
val posts = listOf(post)
Mockito.`when`(service.getPosts()).thenReturn(Single.create {
   it.onSuccess(posts)
})
//blockingGet은 완료 될 때 까지 대기. blockingGet은 내부에서 CountDownLatch 를 사용 한다.
val resultPosts = service.getPosts().blockingGet()
assert(resultPosts.size == 1)
assert(resultPosts == posts)

3.TestObserver

val post = Post("포스트", false)
val posts = listOf(post)
Mockito.`when`(service.getPosts()).thenReturn(Single.create {
   it.onSuccess(posts)
})
service.getPosts().test()
   .assertValueCount(1)
   .assertSubscribed()
   .assertNoErrors()
   .assertValues(posts)

4.Trampoline

@Before
fun setUp() {
  MockitoAnnotations.initMocks(this)
  //Schedulers.trampoline() 을 사용하면 현재 실행되고 있는 Thread 에서 실행 하게 해준다. immediate execution !!
  RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
  RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() }
  RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
  RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
}

@Test
fun whenGetPosts_thenReturnSuccess() {
  val post = Post("첫번째 포스트", false)
  val post2 = Post("두번째 포스트", true)
  val posts = listOf(post, post2)
  Mockito.`when`(service.getPosts()).thenReturn(Single.create {
      it.onSuccess(posts)
  })

   service.getPosts()
      .subscribeOn(Schedulers.newThread())
      .observeOn(Schedulers.newThread())
      .subscribe(
          object : SingleObserver<List<Post>> {
              override fun onSuccess(t: List<Post>) {
                  assert(t == posts)
              }

              override fun onSubscribe(d: Disposable) {
                  compositeDisposable.add(d)
              }

              override fun onError(e: Throwable) {

              }
          }
      )
}

5.ViewModel Unit Test

scheduler , service 파라미터를 가진 ViewModel
@Before
fun setUp() {
  MockitoAnnotations.initMocks(this)
  testScheduler = TestScheduler()
  val schedulerProvider = TestSchedulerProvider(testScheduler)
  viewModel = SchedulerParamViewModel(
      service,
      schedulerProvider
  )
}

@Test
fun test() {
  val post = Post("첫번째 포스트", false)
  val post2 = Post("두번째 포스트", false)
  val posts = listOf(post, post2)
  Mockito.`when`(service.getPosts()).thenReturn(Single.create {
      it.onSuccess(posts)
  })

  viewModel.getPosts()
  testScheduler.triggerActions() //ViewModel 생성 시 주입된 scheduler
  Assert.assertTrue(viewModel.items.value!!.isNotEmpty())
  Assert.assertTrue(viewModel.items.value == posts)
}
service 파라미터를 가진 ViewModel
@Before
fun setUp() {
  MockitoAnnotations.initMocks(this)
  RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
  RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() }
  RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
  RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }

  //Test 코드는 바로 뷰모델을 만들었지만 실제 사용하는 경우 AAC ViewModel 은 파라미터가 있을 경우 ViewModelProvider.Factory 를 Custom 해서 만들면 된다.
  viewModel = GeneralViewModel(service)
}

@Test
fun test() {
  val post = Post("첫번째 포스트", false)
  val post2 = Post("두번째 포스트", false)
  val posts = listOf(post, post2)
  Mockito.`when`(service.getPosts()).thenReturn(Single.create {
     it.onSuccess(posts)
  })

   //getPost 내부에서 scheduler 셋팅을 io , mainThread 로 하지만 @Before 에서 셋팅한 스케줄러 값을 따라간다. 그래서 문제 없이 돌아감
  viewModel.getPosts()

  Assert.assertTrue(viewModel.items.value!!.isNotEmpty())
  Assert.assertTrue(viewModel.items.value == posts)
}

테스트 샘플 프로젝트는 링크를 통해 확인 할 수 있다.

오늘은 UI 상태 저장에 대한 조금 더 깊은 이해와 어떤 옵션들이 있는지 그리고 각각의 옵션들의 장단점에 대해서 알아보려고 한다.

UI 상태를 유지 하기 위한 옵션

  1. ViewModel
  2. Saved instance state
  3. Persistent storage

ViewModel

Android Architecture Components 에 속하는 Component 이다.
메모리에 저장 되고 화면 가로 세로 변경같은 경우 데이터가 저장 되지만 프로세스에 의해 죽고 살아 나는 경우에 데이터는 다시 복구 되지 않는다.
그리고 완벽하게 액티비티가 종료 되어 버리거나 사용자가 백버튼을 눌러 종료 하는 경우 데이터를 복구 할 수 없다.
데이터 제한은 메모리에 저장 되기 때문에 사용가능한 메모리 내에서 가능하고 속도가 빠르다.

Save instance state

저번 커스텀 뷰에서 상태 저장을 위한 onSaveInstanceState 를 사용해서 저장하는 방법이다.
화면 가로 세로 변경 같은 경우, 프로세스 종료 된 이후에도 데이터가 저장 가능 하다.
하지만 ViewModel 과 마찬가지로 완벽하게 액티비티가 종료 되어 버리거나 사용자가 백버튼을 눌러 종료 하는 경우 데이터를 복구 할 수 없다.
데이터 제한은 직렬화되어 디스크에 저장 되기 때문에 primitive type 또는 심플하고 작은 object 만 가능 하다.
직렬화는 데이터를 byte 형태로 변환 하는 것을 말한다.
디스크에 접근하고 serialization , deserialization 을 거치기 때문에 ViewModel 보다 비교적 느리다.

Persistent storage

Shared preferences,SQLite Database,Realm DB 기타 등등..! 디스크에 저장 하거나 네트워크 통신을 통해 서버에 저장하는 것을 의미한다.
이 경우는 모든 경우에 대비해서 데이터를 저장 할 수 있다.
네트워크 통신을 통해 저장 하는 경우 다른 옵션들 보다 비교적 느리지만 데이터를 영구 저장하기에는 제일 좋다고 볼 수 있다.
위와 같이 여러 타입의 UI 상태 저장 방법을 알아보고 비교하는 시간을 가져 보았다.

참고 문서 https://developer.android.com/topic/libraries/architecture/saving-states

커스텀뷰 상태 저장

커스텀뷰가 그려지고 메모리 부족 등으로 인해 다시 그려지는 순간 데이터를 저장한후 다시 가져와야 화면에 그대로 보여줄수 있다.

개발자 모드에서 활동 유지 안함을 활성화 한 후 테스트해야 한다.

아래는 커스텀뷰 상태 저장을 하지 않은 상태이다.

이제부터 데이터 저장을 해보자~!

이때 필요한 메소드는 onSaveInstanceState , onRestoreInstanceState 두개이다.

override fun onSaveInstanceState(): Parcelable? {
        return Bundle().apply {
            putParcelable(KEY_ON_SAVE_INSTANCES_TATE, super.onSaveInstanceState())
            putStringArrayList(KEY_SAVE_DATA, ArrayList(textViewArrayList.map {
                it.text.toString()
            }))
        }
}

override fun onRestoreInstanceState(state: Parcelable?) {
        if (state is Bundle) {
            super.onRestoreInstanceState(state.get(KEY_ON_SAVE_INSTANCES_TATE) as Parcelable)
            state.getStringArrayList(KEY_SAVE_DATA)?.let {
                if (it.isNotEmpty()) {
                    removeAllViews()
                    it.filter { originContent ->
                        originContent != "," && originContent != endText
                    }.forEach { filteredContent ->
                        addText(filteredContent, false)
                    }
                }
            }
        } else {
            super.onRestoreInstanceState(state)
        }
}

활동 유지 안함을 하게 되면 Activity 가 종료된 후 다시 생성되게 되는데 이때 뷰의 onSaveInstanceState 가 호출 된다.

위와같이 StringArrayList 를 onSaveInstanceState 에서 저장하고 다시 생성 될 때 onRestoreInstanceState 가 호출되고 onSaveInstanceState 에서 return 해준 Bundle 이 state 로 넘어온다.

이때 주의 해야하는 점은 super.onRestoreInstanceState()를 호출 해줄 때 super.onSaveInstanceState() 를 저장한 값 그대로 넣어줘야 한다.

아래는 커스텀뷰 상태 저장을 한 상태이다.

데이터 상태를 저장 한 후 다시 불러 왔기때문에 금액 입력이 보이지 않고 원래 적었던 금액이 보이게 된다.

완성도 높은 CustomView 를 만들게 되면 이부분은 필수적인 부분이라고 볼 수 있다.

토스 금액 입력창 애니메이션을 봤는데 생각보다 더 복잡한 애니메이션이 들어가 있었다.

애니메이션 종류는 다음과 같았다.

입력 할 때 애니메이션

  1. 위에서 아래로 내려 감
  2. 위에서 아래로 내려 갈때 투명도 변경
  3. 금액이기 떄문에 콤마가 찍히는데 콤마는 생길 때 fade in

제거 할 때 애니메이션

  1. 원래 위치에서 내려감
  2. 원래 위치에서 내려갈때 투명도 변경
  3. 콤마 제거 될 때 fade out

예정 금액 보다 많을 때 진동과 함께 좌우 흔들리는 애니메이션

총 7가지 애니메이션이 존재 했다.

처음 생각한 방법은 EditText 에서 onDraw 를 커스텀 해서 사용하는 방법이었다.

onDraw 에서 코드로 모두 그리려고 하니 너무 복잡해지는 것 같아 좀 더 간단한 방법이 없을까 고민을 하기 시작함…

어차피 입력은 오른쪽에서부터 쌓이니까 LinearLayout 으로 ViewGroup 을 만들고 텍스트 하나당 TextView 하나를 LinearLayout 으로 쌓아가는 방식으로 만들고 애니메이션을 각각의 뷰에 먹여주면 어떨까!?라는 생각을 하게 되었다.

이 방법을 사용해보니 좀 더 코드가 깔끔해지고 가독성도 많이 좋아 졌다.

예전부터 오픈소스 라이브러리를 만들어보고 싶었는데 도전!!

아래 링크를 타고 들어가보자~!

https://github.com/kimyounghoons/PriceAnimationTextView

서버와 데이터를 주고 받을 때 Json 형식으로 주고 받는데 서버에서 내려주는 데이터 field 가 가끔씩 의도하지 않은 null 값이 내려올 때가 있다.
Kotlin 언어를 사용할 때 nullable 타입인지 아닌지 구분해서 필드를 만들 수 있는데 이 경우는 예상하지 못하기 때문에 무조건 nullable 형태로 사용해야 한다.
이렇게 사용하면 뭔가 깔끔하지 못한 느낌이 들어서 다른 해결 방안을 생각해 보았다.

이 문제를 해결한 방법은 gson 생성 시 TypeAdapter 를 커스텀해서 사용하는 방법이었다.

우선 array 배열에 [null] 형식으로 오는 값과 필드(Boolean , Long , String)가 null 로 오는 두 케이스를 다뤘다.

class GsonHelperTest {
    @Test
    fun `Null to Empty Value`() {
        val jsonString = "{\n" +
                "\"stringValue\": null,\n" +
                "\"timestamp\": \"1602158882721\",\n" +
                "\"longValue\": null,\n" +
                "\"intValue\": null,\n" +
                "\"booleanValue\": null,\n" +
                "\"items\": [null]\n" +
                "    }"

        val gson = GsonBuilder()
                .registerTypeAdapter(ArrayList::class.java, NonNullListDeserializer<Any>())
                .registerTypeAdapter(String::class.java, StringTypeAdapter())
                .registerTypeAdapter(Long::class.java, LongTypeAdapter())
                .registerTypeAdapter(Boolean::class.java, BooleanTypeAdapter())
                .disableHtmlEscaping()
                .create()

        val nonNullData: NonNullData = gson.fromJson(jsonString, NonNullData::class.java)

        println(nonNullData.stringValue.isEmpty())
        println(nonNullData.timestamp)
        println(nonNullData.items.size)
        println(nonNullData.longValue)
        println(nonNullData.intValue)
        println(nonNullData.booleanValue)

        assert(
                nonNullData.stringValue.isEmpty()
        )
    }

    data class NonNullData(
            @SerializedName("stringValue")
            var stringValue: String = "",
            @SerializedName("timestamp")
            var timestamp: String = "",
            @SerializedName("items")
            var items: ArrayList<Long> = arrayListOf(),
            @SerializedName("longValue")
            var longValue: Long = -1L,
            @SerializedName("intValue")
            var intValue: Int = -1,
            @SerializedName("booleanValue")
            var booleanValue: Boolean = true
    )
}

class NonNullListDeserializer<T> : JsonDeserializer<ArrayList<T>> {
    @Throws(JsonParseException::class)
    override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): ArrayList<T> {
        if (json is JsonArray) {
            val array = json
            val size = array.size()
            if (size == 0) {
                return arrayListOf()
            }
            val list: ArrayList<T> = ArrayList(size)
            for (i in 0 until size) {
                val elementType: Type = `$Gson$Types`.getCollectionElementType(typeOfT, ArrayList::class.java)
                val value: T = context.deserialize(array[i], elementType)
                if (value != null) {
                    list.add(value)
                }
            }
            return list
        }
        return arrayListOf()
    }
}


class StringTypeAdapter : TypeAdapter<String>() {
    override fun write(out: JsonWriter, value: String?) {
    }

    override fun read(`in`: JsonReader): String {
        if (`in`.peek() === JsonToken.NULL) {
            `in`.nextNull()
            return ""
        }
        return `in`.nextString()
    }

}

Booelan, Long Type Adapter  같은 구조라 생략

NonNullListDeserializer 를 사용해서 items: [null] 식으로 오는 데이터를 items [] 로 변환 할 수 있고 StringTypeAdapter 를 사용해서 String 으로 보내주는 경우인데 null 값으로 보내주는 경우 null 값이 아닌 “” 빈값 으로 변경 시켜 줄 수 있다.

아래는 테스트 결과 이다.

Retrofit 에 적용 시키는 경우에는 아래와 같이 적용하면 된다.

    private fun createRetrofitClient(okHttpClient: OkHttpClient): Retrofit {
        val builder = Retrofit.Builder()
                .baseUrl(baseUrl)
                .addCallAdapterFactory(rxJava2CallAdapterFactory)
                .addConverterFactory(GsonConverterFactory.create(
                        GsonBuilder().registerTypeAdapter(ArrayList::class.java, NonNullListDeserializer<Any>())
                                .registerTypeAdapter(String::class.java, StringTypeAdapter())
                                .registerTypeAdapter(Long::class.java, LongTypeAdapter())
                                .registerTypeAdapter(Boolean::class.java, BooleanTypeAdapter())
                                .disableHtmlEscaping()
                                .create())
                )
                .addConverterFactory(enumConverterFactory)
        return builder.client(okHttpClient).build()
    }

위와 같이 사용하게 되면 데이터 모델을 만들때 NonNull 타입으로 만들어도 해당 필드나 List 가 null 이 되지 않는걸 신뢰할 수 있다.