본문 바로가기
안드로이드

Android ViewModel Test - Junit4

by 코드 이야기 2023. 9. 6.
728x90

단위 테스트가 무엇인지 이해하고, 적용해 본 경험이 있는 사람을 대상으로 설명하겠습니다.
이 글을 읽고 ViewModel 테스트와 친해지길 바랍니다! 화이팅!

 

Android ViewModel Test

AAC ViewModel은 사전 준비 없이 테스트하게 된다면 테스트에 실패합니다. 여기에는 크게 두 가지 경우가 있습니다.

LiveData value 변경에 대한 테스트, Coroutine과 같은 비동기 작업에 대한 테스트입니다. 두 경우에 대한 실패 원인은 같습니다. 이는 테스트가 MainThread에서 작동하지 않는다는 것입니다.

WorkerThread

테스트는 WorkerThread에서 작동합니다.

테스트는 MainThread에서 수행되지 않고, WorkerThread에서 작동합니다. 이것이 왜 LiveData value 변경 테스트와 Coroutine 테스트의 실패의 원인이 될까요?
우선, 테스트가 WorkerThread에서 작동한다는 것을 확인해 보고, 이것이 왜 테스트 실패의 원인이 되는지 알아보겠습니다. 아래의 코드를 보면 테스트가 Test worker 스레드에서 동작하는 것을 확인할 수 있습니다.

class StudyTestThread {

    @Test
    fun `테스트는 테스트 작업 스레드에서 수행된다`() {
        val currentThreadName = Thread.currentThread().name
        assertEquals("Test worker", currentThreadName)
    }
}

 

LiveData Test

아래의 ViewModel코드를 보면 _products라는 LivaData value에 변경이 발생하고 있습니다.

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에 변경이 발생하고 있는지 확인하고 있습니다.

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() 부분에서 오류가 발생합니다.

setValue() / postValue()

LiveData의 setValue() / postValue()는 MainThread에서 작동합니다.

@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을 설정해주어야 합니다.

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은 안드로이드 앱 구성요소에 대한 테스트를 동기적인 환경에서 실행하고, 메인 스레드에서 동작하는 것처럼 실행합니다.

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 를 사용하는 코드에 대한 테스트를 기준으로 설명드리겠습니다.

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()
        }
    }
}
internal class MainViewModelTest {

    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    private lateinit var sut: MainViewModel
    private lateinit var productRepository: ProductRepository

    @Before
    fun setUp() { /* ... */ }

    @Test
    fun `전체 상품을 조회한다`() { /* ... */ }
}

이 또한 LiveData와 같은 이유로 테스트에 실패합니다.

viewModelScope.launch

viewModelScope.launch는 Dispatcher가 적용되지 않으면 MainThread에서 작동합니다.

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)
        )
    }

테스트는 Test worker 스레드에서 동작하고, Dispatcher가 적용되지 않은 viewModelScope.launch는 Main 스레드에서 작업이 일어나야합니다. 이 과정에서 오류가 발생합니다.

Dispatchers.setMain / Dispatchers.resetMain

스레드가 달라서 발생하는 문제를 해결하기 위해 메인을 사용하는 Dispatcher가 TestDispatcher를 사용하도록 변경합니다. 즉, 테스트가 일어나는 스레드를 통일합니다.
테스트가 종료된 후에는 다시 초기 상태처럼 메인 스레드를 사용할 수 있도록 Dispatcher의 Main을 초기화합니다.

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

앞서 설명 드린 대로 테스트가 시작할 때 스레드를 설정하고, 테스트가 끝날 때 스레드를 다시 설정하는 코드가 있으면 테스트가 조금 더 귀찮아질 겁니다. 그러니 룰을 생성해 이 문제를 해결하도록 하겠습니다.
아래의 코드처럼 룰을 작성해 줍니다.

@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()
    }
}

만들어 둔 룰을 테스트 룰에 적용해 준다면 테스트 시작과 종료 시점에 스레드를 설정하는 코드를 추가로 작성하지 않아도 됩니다.

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/

728x90

댓글