-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
* [feat] Android ViewModel Test(#15) * [feat] Android ViewModel Test(#15) * [feat] Android ViewModel Test(#15) * [refactor] 불필요 코드 제거 * [refactor] 오타 제거 * [fix] br 태그 제거 * [refactor] 코드 캡처를 코드 블록으로 변경하는 등의 코드 가독성 개선 * [refactor] 피드백 반영 * [feat] 글의 대상자 추가 * [refactor] 설명 그림 추가
- Loading branch information
Showing
4 changed files
with
325 additions
and
0 deletions.
There are no files selected for viewing
325 changes: 325 additions & 0 deletions
325
blog/2023-09-06-Android ViewModel Test/2023-09-06-Android ViewModel Test.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,325 @@ | ||
--- | ||
title: Android ViewModel Test | ||
slug: viewmodel-test | ||
authors: [otter] | ||
tags: [android, viewmodel, test] | ||
--- | ||
|
||
안녕하세요! 트립드로우 팀의 수달입니다! | ||
이 글을 통해 안드로이드에서 ViewModel에 대한 테스트를 작성하는 방법에 대해 설명드려고 합니다. | ||
단위 테스트가 무엇인지 이해하고, 적용해 본 경험이 있는 사람을 대상으로 설명하겠습니다. 이 외에도 MVVM, LiveData, Coroutine, Thread에 대해서 완벽히는 아니더라도 알고 사용할 수 있다면 이해에 도움이 될 것이라 생각합니다. | ||
이 글을 읽고 ViewModel 테스트와 친해지길 바랍니다! 화이팅! | ||
|
||
## Android ViewModel Test | ||
|
||
일반적인 ViewModel은 사전 준비 없이 테스트하게 된다면 테스트에 실패합니다. 여기에는 크게 두 가지 경우가 있습니다. ViewModel의 LiveData value 변경에 대한 테스트, Coroutine과 같은 비동기 작업에 대한 테스트입니다. 두 경우에 대한 실패 원인은 같습니다. 이는 테스트가 MainThread에서 작동하지 않는다는 것입니다. | ||
|
||
### WorkerThread | ||
|
||
테스트는 WorkerThread에서 작동합니다. | ||
|
||
테스트는 MainThread에서 수행하지 않고, WorkerThread에서 작동합니다. 이것이 왜 LiveData value 변경 테스트와 Coroutine 테스트의 실패의 원인이 될까요? | ||
우선, 테스트가 WorkerThread에서 작동한다는 것을 확인해 보고, 이것이 왜 테스트 실패의 원인이 되는지 알아보겠습니다. 아래의 코드를 보면 테스트가 "Test worker" 스레드에서 동작하는 것을 확인할 수 있습니다. | ||
|
||
```kotlin | ||
class StudyTestThread { | ||
|
||
@Test | ||
fun `테스트는 테스트 작업 스레드에서 수행된다`() { | ||
val currentThreadName = Thread.currentThread().name | ||
assertEquals("Test worker", currentThreadName) | ||
} | ||
} | ||
``` | ||
|
||
## LiveData Test | ||
|
||
아래의 `ViewModel`코드를 보면 \_products라는 LivaData value에 변경이 발생하고 있습니다. | ||
|
||
```kotlin | ||
class MainViewModel(private val productRepository: ProductRepository) : ViewModel() { | ||
|
||
private val _products: MutableLiveData<List<Product>> = MutableLiveData(emptyList()) | ||
val products: LiveData<List<Product>> get() = _products | ||
|
||
fun fetchAllProducts() { | ||
_products.value = productRepository.getAllProducts() | ||
} | ||
} | ||
``` | ||
|
||
아래의 `ViewModelTest` 코드를 보면 products라는 LivaData value에 변경이 발생하고 있는지 확인하고 있습니다. | ||
|
||
```kotlin | ||
internal class MainViewModelTest { | ||
|
||
private lateinit var sut: MainViewModel | ||
private lateinit var productRepository: ProductRepository | ||
|
||
@Before | ||
fun setUp() { | ||
productRepository = Fake.ProductRepository() // 테스트를 위한 가짜 객체로 설정해줍니다 | ||
sut = MainViewModel(productRepository) | ||
} | ||
|
||
@Test | ||
fun `전체 상품을 조회한다`() { | ||
// given | ||
val products = productRepository.getAllProducts() | ||
|
||
// when | ||
sut.fetchAllProducts() | ||
|
||
// then | ||
assertEquals(products, sut.products.value) | ||
} | ||
} | ||
``` | ||
|
||
이렇게 테스트하면 LiveData의 value를 변경하는 `sut.fetchAllProducts()` 부분에서 오류가 발생합니다. | ||
![Alt text](image-3.png) | ||
|
||
### setValue() / postValue() | ||
|
||
LiveData의 setValue() / postValue()는 MainThread에서 작동합니다. | ||
|
||
```java | ||
@MainThread | ||
protected void setValue(T value) { | ||
assertMainThread("setValue"); // 메인 스레드에서 수행 | ||
mVersion++; | ||
mData = value; | ||
dispatchingValue(null); | ||
} | ||
|
||
protected void postValue(T value) { | ||
boolean postTask; | ||
synchronized (mDataLock) { | ||
postTask = mPendingData == NOT_SET; | ||
mPendingData = value; | ||
} | ||
if (!postTask) { | ||
return; | ||
} | ||
// 메인 스레드에서 수행 | ||
ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable); | ||
} | ||
``` | ||
|
||
앞서 설명드렸듯이 테스트는 `Test worker` 스레드에서 동작하지만, 테스트에서 확인하고자하는 LiveData의 값 변경은 `Main` 스레드에서 작업이 일어나야합니다. 이 과정에서 오류가 발생합니다. | ||
|
||
### InstantTaskExecutorRule() | ||
|
||
스레드가 달라서 발생하는 문제를 해결하기 위해 InstantTaskExecutorRule을 설정해주어야 합니다. | ||
|
||
```kotlin | ||
internal class MainViewModelTest { | ||
|
||
@get:Rule | ||
val instantExecutorRule = InstantTaskExecutorRule() //rule 설정 | ||
|
||
private lateinit var sut: MainViewModel | ||
private lateinit var productRepository: ProductRepository | ||
|
||
@Before | ||
fun setUp() { /* ... */ } | ||
|
||
@Test | ||
fun `전체 상품을 조회한다`() { /* ... */ } | ||
} | ||
``` | ||
|
||
[InstantTaskExecutorRule](https://developer.android.com/reference/androidx/arch/core/executor/testing/InstantTaskExecutorRule)은 안드로이드 앱 구성요소에 대한 테스트를 동기적인 환경에서 실행하고, 메인 스레드에서 동작하는 것처럼 실행합니다. | ||
|
||
```java | ||
public class InstantTaskExecutorRule extends TestWatcher { | ||
@Override | ||
protected void starting(Description description) { | ||
super.starting(description); | ||
ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() { | ||
@Override | ||
public void executeOnDiskIO(@NonNull Runnable runnable) { | ||
runnable.run(); | ||
} | ||
|
||
@Override | ||
public void postToMainThread(@NonNull Runnable runnable) { | ||
runnable.run(); | ||
} | ||
|
||
@Override | ||
public boolean isMainThread() { | ||
return true; // 항상 메인 스레드인 것처럼 사용할 수 있습니다. | ||
} | ||
}); | ||
} | ||
|
||
@Override | ||
protected void finished(Description description) { | ||
super.finished(description); | ||
ArchTaskExecutor.getInstance().setDelegate(null); | ||
} | ||
} | ||
``` | ||
|
||
## Coroutine Test | ||
|
||
`viewModelscope.launch`를 사용하는 코드에 대한 테스트를 기준으로 설명드리겠습니다. | ||
|
||
```kotlin | ||
class MainViewModel(private val productRepository: ProductRepository) : ViewModel() { | ||
|
||
private val _products: MutableLiveData<List<Product>> = MutableLiveData(emptyList()) | ||
val products: LiveData<List<Product>> get() = _products | ||
|
||
fun fetchAllProducts() { | ||
viewModelScope.launch { | ||
_products.value = productRepository.getAllProducts() | ||
} | ||
} | ||
} | ||
``` | ||
|
||
```kotlin | ||
internal class MainViewModelTest { | ||
|
||
@get:Rule | ||
val instantExecutorRule = InstantTaskExecutorRule() | ||
|
||
private lateinit var sut: MainViewModel | ||
private lateinit var productRepository: ProductRepository | ||
|
||
@Before | ||
fun setUp() { /* ... */ } | ||
|
||
@Test | ||
fun `전체 상품을 조회한다`() { /* ... */ } | ||
} | ||
``` | ||
|
||
이 또한 LiveData와 같은 이유로 테스트에 실패합니다. | ||
|
||
![Alt text](image-11.png) | ||
|
||
### viewModelScope.launch | ||
|
||
viewModelScope.launch는 Dispatcher가 적용되지 않으면 MainThread에서 작동합니다. | ||
|
||
```kotlin | ||
public val ViewModel.viewModelScope: CoroutineScope | ||
get() { | ||
val scope: CoroutineScope? = this.getTag(JOB_KEY) | ||
if (scope != null) { | ||
return scope | ||
} | ||
return setTagIfAbsent( | ||
JOB_KEY, | ||
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) | ||
) | ||
} | ||
``` | ||
|
||
![Alt text](image.png) | ||
|
||
테스트는 `Test worker` 스레드에서 동작하고, 코루틴은 `Main` 스레드에서 작업이 일어나야합니다. 이 과정에서 오류가 발생합니다. | ||
|
||
### Dispatchers.setMain / Dispatchers.resetMain | ||
|
||
스레드가 달라서 발생하는 문제를 해결하기 위해 메인을 사용하는 Dispatcher가 TestDispatcher를 사용하도록 변경합니다. 즉, 테스트가 일어나는 스레드를 통일합니다. | ||
테스트가 종료된 후에는 다시 초기 상태처럼 메인 스레드를 사용할 수 있도록 Dispatcher의 Main을 초기화합니다. | ||
|
||
```kotlin | ||
internal class MainViewModelTest { | ||
|
||
@get:Rule | ||
val instantExecutorRule = InstantTaskExecutorRule() | ||
|
||
private lateinit var sut: MainViewModel | ||
private lateinit var productRepository: ProductRepository | ||
|
||
@Before | ||
@OptIn(ExperimentalCoroutinesApi::class) | ||
fun setUp() { | ||
Dispatchers.setMain(UnconfinedTestDispatcher()) // Main Dispatcher를 변경한다. | ||
productRepository = Fake.ProductRepository() | ||
sut = MainViewModel(productRepository) | ||
} | ||
|
||
@After | ||
@OptIn(ExperimentalCoroutinesApi::class) | ||
fun tearDown() { | ||
Dispatchers.resetMain() // Main Dispatcher를 초기 상태로 복구시킨다. | ||
} | ||
|
||
@Test | ||
fun `전체 상품을 조회한다`() { | ||
// given | ||
val products = productRepository.getAllProducts() | ||
|
||
// when | ||
sut.fetchAllProducts() | ||
|
||
// then | ||
assertEquals(products, sut.products.value) | ||
} | ||
} | ||
|
||
``` | ||
|
||
### Coroutine Rule | ||
|
||
앞서 설명 드린 대로 테스트가 시작할 때 스레드를 설정하고, 테스트가 끝날 때 스레드를 다시 설정하는 코드가 있으면 테스트가 조금 더 귀찮아질 겁니다. 그러니 룰을 생성해 이 문제를 해결하도록 하겠습니다. | ||
아래의 코드처럼 룰을 작성해 줍니다. | ||
|
||
```kotlin | ||
@OptIn(ExperimentalCoroutinesApi::class) | ||
class MainCoroutineRule constructor( | ||
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() | ||
) : TestWatcher() { | ||
|
||
@OptIn(ExperimentalCoroutinesApi::class) | ||
override fun starting(description: Description) { | ||
super.starting(description) | ||
Dispatchers.setMain(testDispatcher) | ||
} | ||
|
||
@OptIn(ExperimentalCoroutinesApi::class) | ||
override fun finished(description: Description) { | ||
super.finished(description) | ||
Dispatchers.resetMain() | ||
} | ||
} | ||
``` | ||
|
||
만들어 둔 룰을 테스트 룰에 적용해 준다면 테스트 시작과 종료 시점에 스레드를 설정하는 코드를 추가로 작성하지 않아도 됩니다. | ||
|
||
```kotlin | ||
internal class MainViewModelTest { | ||
|
||
@get:Rule | ||
val instantExecutorRule = InstantTaskExecutorRule() | ||
|
||
@get:Rule | ||
val mainCoroutineRule = MainCoroutineRule() // Dispatchers를 사용하는 스레드가 메인 스레드가 되도록 합니다. | ||
|
||
private lateinit var sut: MainViewModel | ||
private lateinit var productRepository: ProductRepository | ||
|
||
@Before | ||
fun setUp() { | ||
productRepository = Fake.ProductRepository() | ||
sut = MainViewModel(productRepository) | ||
} | ||
|
||
// 이제 매번 설정해줄 필요가 없어졌습니다! | ||
|
||
@Test | ||
fun `전체 상품을 조회한다`() { /* ... */ } | ||
} | ||
``` | ||
|
||
이해가 잘 되셨기를 바라며... 총총... | ||
|
||
## 참고자료 | ||
|
||
https://tech.kakao.com/2021/11/08/test-code/ |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.