안드로이드 MVVM에서의 테스트 검증을 더 잘하려면?
개인 광고 영역
안드로이드 MVVM을 효과적으로 작성하려면 알아야 할 것은 무엇일까? 그리고 왜 MVVM을 하려고 하는 것일까?
우리는 항상 새로운 것을 갈망하는데, 사실 안드로이드가 너무 고여서 재미가 없기 때문이다.
10년이 넘은 안드로이드는 초기에는 안드로이드 플랫폼의 변화에 대응하였고, MVP라는 패턴을 소개하기 시작하면서 패턴에 관심을 가지기 시작했다.
거기에 kotlin으로 언어도 변화되었고, RxJava, Coroutines, Flow 등 새로운 라이브러리가 계속 나오고 있다.
기반이 바뀐 것이다.
새로 시작하는 분들을 기준으로 한다면 엄청나게 덩치 큰 걸 한 번에 주입해야 한다는 문제가 생긴다.
따라 하는 건 누구나 하고, 동작하는 것 역시 누구나 만들 수 있다.
다만 좀 더 재미있고, 새로운 방식에 고통받으며 재미를 찾아가는 재미도 함께 해야 한다.
이 글은 지극히 주관적인 생각을 담고 있는 글이다.
이 글에서 알아볼 내용
- MVVM과 MVP
- MVVM에서의 테스트 검증
- 테스트 검증을 좀 더 잘해보자.
MVVM과 MVP는?
MVVM은 무엇일까?
Model View ViewModel로 나뉜다. 하지만 이 나뉘는 과정은 MVP에서 이미 끝난다. MVP와 MVVM의 명확한 차이는 아래와 같다.
- View와 로직을 분리하는 건 동일하다.
- View Context를 로직을 담당하는 Presenter나 ViewModel이 가져서는 안된다.
- Context를 주입하면 Activity에서 할 수 있는 모든 걸 할 수 있기 때문이다.
- 그럼 Presenter나 ViewModel을 분리해야 할 이유가 사라진다.
- Presenter에서는 View라는 interface를 주입해 준다. 하지만 ViewModel은 LiveData나 Flow를 활용하여 옵저빙 형태로 변형된다.
- Presenter에서는 DataBinding을 굳이 쓸 이유는 없지만, ViewModel은 함께 활용하면 편하다.
- Presenter에서는 Di(Dagger, Hilt)를 활용해야 할 이유가 특별히 없지만, ViewModel은 Di를 기본으로 활용한다.
P는 View와 매우 강한 결합을 하지만, VM은 View와의 결합도를 느슨하게 바꾼다.
나머지 비즈니스 로직을 나누는 방식은 P나 VM이나 특별히 다르지 않다.
Presenter
Presenter에 등록된 View로 직접 통지해 줄 것인가?
interface Contract {
interface View {
fun show(items: List<SomeItem>)
}
interface Presenter {
fun load()
}
}
class SamplePresenter(private val view: Contract.View) : Contract.Presenter {
override fun load() {
view.show(...)
}
}
ViewModel
아니면 Observe를 제공하여 느슨하게 던져 줄 것인가.
class SampleViewModel {
private val _show = MutableLiveData<List<SomeItem>>()
val show: LiveData<List<SomeItem>> get() = _show
fun load() {
_show.value = listOf(...)
}
}
P와 VM은 코드만 보아도 View에게 직접 통지냐 View가 알아서 통지받을 것인가의 차이가 있다.
구독은 굳이 필요치 않다면 하지 않을 수 있다. 그래서 같은 형태의 VM을 여러 다른 Activity에서 활용할 수 있다.
MVVM에서의 테스트?
MVVM을 활용하면 테스트가 중요하다고 한다.(MVP 도 동일하다.)
왜일까? 이건 여러 문서에서 소개하고 있는 내용이다. MVC는 View에서 컨트롤의 통지를 받고, 모델을 가져와 화면에 노출하는 데 이를 UI 테스트를 통해 검증이 가능하다.
UI 테스트는 현실적으로 쉽지 않다. 매 업데이트마다 화면이 변경될 수 있다.
그래서 선택할 수 있는 부분은 바로 비즈니스 테스트이다. 그러기 위해 MVP도 나왔고, 결합도를 줄이는 방식으로의 접근인 MVVM도 나온 것이다.
이런 아키텍처 자체가 출몰한 이유는 다 있다.
MVVM의 궁극적 목표는?
- 테스트 코드의 작성 가능성이 아닌 작성
- 로직의 분리?
- 있어 빌리티
그럼 현실적으로 테스트 코드 가능성이 가능할지 한번 파악해 보자.
테스트 코드는 왜 작성해야 할까?
테스트 코드의 중요성은 누구나 알고 있다. 예상치 못한 오류를 미리 잡을 수 있다?
사실 이것보단 내가 작성한 코드의 검증이다. 필자가 테스트 코드 작성하면서 경험한 부분은 수정 케이스이다.
초깃값을 임시로 바꿔두었는데, 이 값의 검증이다. 사람은 누구나 실수하고, 커밋 하다 보면 그냥 넘어가기도 한다.
이럴 때 테스트 코드를 통해 검증해둔 값을 사전에 검증하면서 초깃값의 문제를 찾기도 하였다.
결국 재수정 시 발생할 수 있는 검증에 도움이 될 수 있다.
UI는 사실 매번 변경 가능성이 있어서 이러한 검증은 어렵다. UI는 정률화되어있는 테스트를 진행하기 때문이다. 그래서 비즈니스 로직에 초점을 맞춰 테스트하는 편이 좋다.
테스트로 어디까지 검증할 수 있을까?
정말 우리가 작성하는 코드가 테스트 검증이 되는 것일까?
일반적으론 아래와 같은 코드를 작성한다.
class SampleViewModel {
private val _showOtherActivity = MutableLiveData(false)
val showOtherActivity: LiveData<Boolean> get() = _showOtherActivity
private val _showAlert = MutableLiveData(false)
val showAlert: LiveData<Boolean> get() = _showAlert
fun verify(typedData: String) {
// ... 값의 검증
if (typedData.isNotEmpty()) {
_showOtherActivity.value = true
} else {
_showAlert.value = true
}
}
fun confirm() {
// 여기에서 실제 컴펌이 일어난다.
}
}
그럼 View에서는 Confirm Activity를 노출하는 코드와 alertDialog를 노출하는 observe를 설정한다.
이 코드는 어디까지나 샘플
class SampleActivity {
private val viewModel by viewModels()
private val confirm = registerForActivityResult(Confirm()) { uri: Uri? ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
// Handle the Intent
viewModel.confirm()
}
}
override fun onCreate() {
// ...
binding.button.setOnClickListener {
viewModel.verify(binding.edit.text.toString())
}
viewModel.showOtherActivity.observe(this) {
confirm.launch()
}
viewModel.showAlert.observe(this) {
// Show alert
}
// ...
}
}
이러한 코드의 ViewModel 검증은 어디까지 진행할 수 있을까?
딱 2가지 검증이 가능하다.
- verify의 typedData가 비어있는 경우 _showAlert.value가 true 인지 검증한다.
- verify의 typedData가 비어있지 않을 경우 _showOtherActivity.value가 true 인지 검증한다.
실제 Activity가 노출되었는지, AlertDialog가 노출되었는지는 알 길이 없다.
그럼 이러한 검증이 필요할까? 있어도 좋고, 없어도 그만이다. 테스트 돌릴 시간에 그냥 돌려보는 편이 더 효과적이다. 어차피 원하는 응답 값이 오는지 안 오는지는 런타임이나 UI 테스트에서나 검증할 수 있다.
그래서 보통 아래와 같은 경우 검증을 한다.
- 네트워크를 통해 데이터가 정상적으로 들어왔을 경우
- 네트워크를 통해 데이터가 비정상적으로 들어와서 오류가 났을 경우
이 방법은 어떤가?
정말 검증을 해야 한다면 한 사이클로 검증이 이루어진다면 매우 좋을 것이다.
단순히 verify
함수가 호출되었다면 이후 과정은 한 사이클로 모두 테스트가 가능하다면 더 좋을 것이다.
예를 들어보자.
- 버튼 클릭이 발생하여
verify
함수가 호출되었다. - 값이 비어있을 경우
- alertDialog가 노출되고, alertDialog의 메시지, 버튼, 제목, 버튼이 눌러지는 과정까지 모두 검증한다.
- alertDialog가 끝나면 이후 동작은 없다.
- 버튼 클릭이 발생하여
verify
함수가 호출되었다. - 값이 있을 경우
- otherActivity가 노출되었는지 확인한다.
- otherActivity에서 정상적인 케이스의 응답을 줌을 검증한다.
- otherActivity의 응답이 정상이면 confirm() 함수의 이후 동작을 수행한다.
지금 구조상으론 모두 UI 테스트에서나 검증 가능하다.
생각을 바꿔보자.
우리는 자바를 학습하고, 솔리드를 학습한다. 여기에는 아주 좋은 규칙이 있다.
- Interface segregation principle (인터페이스 분리 원칙)
- Single-responsibility principle (단일 책임 원칙)
사실 직접 설명하라면 말하기 어려운 원칙들이다. 그래서 어디에서 써야 하는지 잘 모르기 때문이다. 문서나 책을 참고하시길…
위의 과정을 한 사이클로 처리하려면 일단 인터페이스 분리 원칙, 단일 책임 원칙을 잘 따라 설계하면 되겠다.
- alertDialog 인터페이스를 구현한다.
- Activity 접근을 간접적으로 할 수 있는 Router interface 구현한다.
ViewModel에서는 이 두 개의 인터페이스를 명시한다.
이 2개의 인터페이스 적용만으로도 2가지다 검증이 가능해졌다. 함수가 호출되었는지에 대한 실제 검증이 가능해지니 말이다.
아참 실제 접근 시키려면 Context가 필요한데, 구현체를 별도로 두고 있어, ViewModel이 직접 Context를 활용하는 것은 아니다.(MVP와 유사하다.)
이제 Mockito와 같은 툴을 활용해 mock 테스트가 가능해졌다.
검증은?
Mockito를 통해 검증을 진행하게 되는데,
val alertDialgo = mock<AlertDialog>()
val router = mock<Router>()
와 같이 mock 검증을 할 수 있다. 그럼 실제 값이 호출되었는지를 verify
를 통해 검증이 가능해진다.
최소한 응답을 주지는 못하더라도, 내가 요청한 값이 잘 나왔는지, 실제로 호출되었는지도 검증할 수 있다.
이것만 하더라도 기존에 연결이 끊어져 이게 검증한 것인가?에 대한 해답은 충분히 했다.
이러한 검증 절차가 많아지기 때문에 Mock class를 미리 만들어둘 필요가 있다.
사실 이런 형태로 만들기 시작하면 Android-alatan LifecycleComponents의 형태가 만들어진다.
버튼 클릭 이벤트를 별도로 처리하기
이미 이전에 작성한 글이 있다. View와 ViewModel 호출을 직접이 아닌 간접적으로 할 수 있는 방법이다.
- Android view에서 ViewModel을 어떻게 호출해 사용해 볼 수 있을까?
- Android view에서 ViewModel을 호출했는데, UnitTest는 어떻게 할까?
- Android AAC-ViewModel 대신 Lifecycle을 적용해 보자.
마무리
MVVM을 활용하는 건 생각보다 쉽다. View에 필요한 데이터를 ViewModel에서 불러오고, Model을 View에서 직접 호출하지 않도록 만드는 것이다.
그리고 Observe 형태로 데이터를 통지해 주는 것이다. 여기에 DI를 추가한다.
하지만 MVVM의 사용 목적이 그것만은 아니다. 테스트 검증도 함께 되어야 한다.
테스트 검증을 통해 무엇을 검증할지도 중요하다.
필자의 경우 android-alatan 라이브러리를 그대로 활용하고 있다. 이를 통해 검증까지 모두 진행하고 있는데, 이런 기반들을 함께 하고 있다.
사실 소규모의 프로젝트에서는 덩치가 너무 크긴 하다. 하지만 검증을 하다 보면 이게 왜 좋은지를 알게 된다.
그래서 따라 하기는 쉽지만 왜 해야 하는지는 아는 데까지 걸리는 시간이 오래 걸린다.
Comments