Android/Android

[Android:Codelab] Testing Basic-3

RIEN😚 2023. 1. 21. 00:33
728x90
반응형

👇🏻 참고문서

 

Advanced Android in Kotlin 05.1: Testing Basics  |  Android Developers

Learn the basics of testing your Android Kotlin apps. In this codelab you’ll learn to run tests, write basic tests, work with AndroidX Test, as well as test ViewModel and LiveData.

developer.android.com

안드로이드 독학하기 시리즈! 테스트입니다.

Testing Basic의 마지막 ViewModel과 LiveData를 테스트하는 방법입니다.🤗

 

6. AndroidX Test로 ViewModel 테스트 Setting하기

코드랩 프로젝트 코드 중 TasksViewModel를 위한 테스트 코드를 작성하면서 시작해보겠습니다.

🌹 해당 파일은 todoapp/tasks/TasksViewModel에 위치합니다.

여기서 주의할 점은 이번에 테스트하고자 하는 모든 로직들은 viewModel내에 있는 코드들 뿐입니다! 때문에 viewModel 내부의 repository에는 의존하지 않아야 한다는 점에 유의해야 합니다.

TasksViewModel 내에는 많은 코드가 들어있지만 이 중에서

addNewTask 메서드가 호출되었을 때, 새로운 task를 작성하는 화면으로 이동하는 Event를 발생시키는지 검사해볼 것입니다.

fun addNewTask() {
	_newTaskEvent.value = Event(Unit)
}

 

6.1 TaskViewModelTest 클래스 만들기

이전 포스트에서 특정 메서드를 테스트하기 위한 테스트 클래스를 만들었던 것과 같이, viewModel을 위한 테스트 코드를 작성할 클래스를 만들어주어야 합니다.

🔥 테스트 클래스를 자동으로 만드는 방법은 이전 메서드를 위한 테스트 클래스를 만들었던 것과 동일하게 진행됩니다.👍🏻
  1. TasksViewModelTest 라는 이름으로 테스트 클래스를 만들어줍니다.
  2. Unit Test이기 때문에 androidTest가 아닌 test에 위치해야 합니다.
왜 Presentation Layer에 있는 ViewModel이 Local test인 걸까?

- Pure한 viewModel은 Android 프레임워크에 의존하지 않고 동작하는 코드들로 구성되어 있기 때문입니다.

 

아래와 같은 클래스가 test 디렉토리 하위에 생성된 것을 확인할 수 있습니다.

internal class TasksViewModelTest

 

6.2 ViewModel 테스트 작성 시작하기

먼저 테스트 함수를 작성해주겠습니다. ( Given / When / Then 구조를 잊지말자! )

@Test
fun addNewTask_setNewTaskEvent() {
    // 1. Given: a fresh TasksViewModel

    // 2. When: adding a new Task

    // 3. Then: the new task event is triggered
}

 

가장 먼저 해줘야 하는 것은 테스트할 TasksViewModel 객체를 생성하는 것입니다.

 

TasksViewModel의 생성자를 보면 Application Context를 요구하고 있습니다.

하지만.. 우리는 지금 테스트 코드 작성 중..  어떻게 application context를 받아올 수 있을까요?

val tasksViewModel = TasksViewModel(???)

이 때 사용할 수 있는 것이 바로 AndroidX Test 라이브러리입니다.

 

AndroidX Test 라이브러리는 테스트를 위한 Application, Activity와 같은 component들을 제공해주는 클래스들과 메서드들을 포함하고 있습니다.

따라서 만약 local test에서 Android 프레임워크 클래스들(ex. application context)이 필요하다면, 아래 step들을 순서대로 따라해보세요!

  1. AndroidX Test core와 ext dependencies를 추가해준다.
  2. Robolectric Testing 라이브러리 dependency를 추가해준다.
  3. 테스트 클래스에 AndroidJunit4 test runner 어노테이션을 추가해준다.
  4. AndroidX Test 코드를 작성해준다.

위 step들을 하나씩 살펴보자!🔥

🌹 만약 viewModel에서 application context와 같은 Android 프레임워크와 관련된 객체가 필요하지 않은 경우에는 굳이 이렇게 dependency들을 추가하지 않고, viewModel 객체를 바로 생성하면 됩니다. 

 

6.3 dependency들 추가하기

// 1.1.3
testImplementation "androidx.test.ext:junit-ktx:$androidXTestExtKotlinRunnerVersion"
// 1.3.0
testImplementation "androidx.test:core-ktx:$androidXTestCoreVersion"
// 4.9.2
testImplementation "org.robolectric:robolectric:$robolectricVersion"

 

6.4 JUnit Test Runner 등록하기

@RunWith(AndroidJUnit4::class)
internal class TasksViewModelTest { // Test code }

 

6.5 AndroidX Test 사용하기

ApplicationProvider.getApplicationContext

- application Context를 반환해줍니다.

 

이걸 사용하면 요렇게 Given과 When 부분을 작성할 수 있습니다.

@RunWith(AndroidJUnit4::class)
internal class TasksViewModelTest {

    @Test
    fun addNewTask_setNewTaskEvent() {
        // 1. Given: a fresh TasksViewModel
        val viewModel = TasksViewModel(
            ApplicationProvider.getApplicationContext()
        )

        // 2. When: adding a new Task
        viewModel.addNewTask()

        // 3. Then: the new task event is triggered
    }
}

 

 

여기서 잠깐!! 

6.6 What is AndroidX Test?

- test를 위한 라이브러리들의 모음(collection)입니다.

- test를 위한 Application, Activity와 같은 component들을 제공해주는 클래스들과 메서드들을 포함하고 있습니다.

 

📌 6.6.1 AndroidX Test의 장점

- local test와 instrumented test 둘 다에서 동일한 방법으로 사용할 수 있습니다.

- local test와 instrumented test를 위해서 별도의 test API를 배울 필요가 없습니다.

 

예를 들어, 앞에서 작성한 TasksViewModelTest 클래스는 test 디렉토리 하위에 존재하고 있지만, instrumented test를 위해 androidTest 디렉토리 하위로 이동해도 정상적으로 작동한다는 의미입니다.

 

정상적으로 작동은 한다는 거지 완전히 동일하게 동작한다는 의미는 아닙니다.

application Context를 얻어오기 위해 사용했던 getApplicationContext는 local test와 instrumented test에서 사용할 수 있지만,

  • instrumented test: 실제 Application Context를 반환
  • local test: simulated Android environment를 사용

 

📌 6.6.2 What is Robolectric?

🌹 local test를 위해 사용하는 AndroidX Test의 simulated Android environment는 Robolectric에 의해 제공되는 점에 유의하세요!

Robolectric은 테스트를 위한 simulated Android environment를 생성해주고 실제 기기(또는 emulator)를 부팅하는 것보다 빠르게 실행되도록 해주는 라이브러리입니다.

만약, simulated Android environment를 사용할 때 Robolectric dependency를 추가하지 않는다면 아래 에러를 만나게 될 것입니다.

  • No such manifest file: ./AndroidManifest.xml
  • "WARN: Android SDK 29 requires Java 9..."
No such manifest file: ./AndroidManifest.xml 인 경우
// app/build.gradle
// Always show the result of every unit test when running via command line,
// even if it passes
testOptions.unitTests {
	includeAndroidResources = true
    // ...
}

 

📌 6.6.3 @RunWith(AndroidJUnit4::class)?

test runner

- test를 실행하기 위한 JUnit component입니다. test runner 없이는 test를 실행할 수 없습니다.

- JUnit은 원래 자동적으로 default test runner를 제공해주지만, @RunWith를 통해 default test runner를 원하는 test runner로 교체할 수 있습니다.

- AndroidJUnit4 test runner는 AndroidX Test가 local test와 instrumented test에 따라 다르게 동작할 수 있도록 해줍니다.

 

6.7 Summary

  • Pure viewModel tests는 대게 Android 프레임워크를 요구하지 않으므로 test 디렉토리 내에 위치할 수 있습니다.
  • Application이나 Activity와 같은 component를 가져오고자 할 때는 AndroidX test library를 사용할 수 있습니다.
  • test 디렉토리 내에서 simulated Android code를 실행할 때는 반드시 Robolectric dependency과 클래스에 @RunWith(AndroidJUnit4::class) 어노테이션을 추가해주어야 합니다.

 

7. LiveData로 Assertions 작성하기

LiveData를 테스트하기 위해서 다음 2가지 작업을 수행해야 합니다.

- InstantTaskExecutorRule을 사용한다.

- LiveData를 관찰합니다.

 

7.1 InstantTaskExecutorRule를 사용하기

InstantTaskExecutorRule은 JUnit Rule입니다.

@get:Rule 어노테이션을 사용하게 된다면, InstantTaskExecutorRule 클래스 내에 코드를 테스트 전/후에 실행시킬 수 있습니다.

 

실제로 InstantTaskExecutorRule 내부코드는 아래처럼 starting과 finished로 이루어져 있습니다.

public class InstantTaskExecutorRule extends TaskWatcher {
	// Description: describes test
	@Override
    protected void starting(Description description) {
    	super.starting(description) // 부모에는 아무런 코드가 없다.
        ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() {
        	@Override
            public void executeOnDiskID(Runnable runnable) { runnable.run() }
            @Override
            public void postToMainThread(Runnable runnable) { runnable.run() }
            @Override
            public boolean isMainThread() { return true; }
        })
    }
    
    @Override
    protected void finished(Description description) {
    	super.finished(description)
        ArchTaskExecutor.getInstance().setDelegate(null)
    }
}

 

조금 더 깊게 가보자. ArchTaskExecutor의 setDelegate 메서드는 무엇을 setting하는 것일까?

내부 코드에서는 아래처럼 설명하고 있습니다.

public void setDelegate(@Nullable TaskExecutor taskExecutor) {
    mDelegate = taskExecutor == null ? mDefaultTaskExecutor : taskExecutor;
}

이 setDelegate는 task 실행 요청을 파라미터로 전달한 taskExecutor에 위임하도록 setting 해주는 메서드입니다.

실제로 ArchTaskExecutor 내부의 코드를 보면 아래와 같이 setDelegate에서 초기화한 mDelegate의 메서드를 호출하는 것을 알 수 있습니다.

@Override
public void postToMainThread(Runnable runnable) {
    mDelegate.postToMainThread(runnable);
}

 

이 부분에서 의문이 들기 시작합니다. 왜 LiveData를 테스트하는데, ArchTaskExecutor를 선행적으롤 처리해주는 것일까?

이를 알기 위해서는 테스트하고자 하는 LiveData의 내부 동작을 알고 있어야 합니다.

 

(LiveData).setValue
@MainThread
protected void setValue(T value) {
    assertMainThread("setValue");
    mVersion++;
    mData = value;
    // observing하고 있는 consumer에게 전달
    dispatchingValue(null);
}


static void assertMainThread(String methodName) {
    if (!ArchTaskExecutor.getInstance().isMainThread()) {
        throw new IllegalStateException(
        	"Cannot invoke " + methodName + " on a background" + " thread"
        );
    }
}

setValue의 내부에 ArchTaskExecutor의 isMainThread를 호출하는 것을 알 수 있습니다.

 

(LiveData).postValue
protected void postValue(T value) {
    boolean postTask;
    synchronized (mDataLock) {
        postTask = mPendingData == NOT_SET;
        mPendingData = value;
    }
    if (!postTask) {
        return;
    }
    ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
}

postValue에서도 동일하게 ArchTaskExecutor의 postToMainThread 메서드를 호출하는 것을 알 수 있습니다.

🌹 이렇게 LiveData 내부에서 사용하는 ArchTaskExecutor를 custom하기 위해서 InstantTaskExecutorRule를 사용하는 것이라 생각합니다.

 

여기서 잠시! postValue에 대해서 알아보도록 하겠습니다. 🤗

postValue는 postToMainThread 메서드에 mPostValueRunnable이라는 Task를 전달하게 되는데, mPostValueRunnable은 아래와 같이 구현되어 있습니다.

private final Runnable mPostValueRunnable = new Runnable() {
        @SuppressWarnings("unchecked")
        @Override
        public void run() {
            Object newValue;
            synchronized (mDataLock) {
                newValue = mPendingData;
                mPendingData = NOT_SET;
            }
            setValue((T) newValue);
        }
    };
  • postValue는 setValue와 달리 background thread에서 값을 바꿀 때 사용할 수 있습니다.
  • mainThread로 변경을 post하기 때문에 mainThread가 작업을 처리할 때까지 대기하다 mainThread가 비었을 때에야 작업을 처리하게 됩니다.
liveData.post("a")
liveData.setValue("b")

때문에 위의 코드를 수행하면 b가 먼저 setting 되고 이후에 a로 setting 될 것입니다.

  • mainThread가 post된 작업을 처리하기 전에 postValue가 여러번 호출되었다면 마지막 값만 처리되는 것에 주의하세요.

 

이제 진짜로 InstantTaskExecutorRule을 사용해보자

1. Architecture Components core testing library dependency 추가하기
// archTestingVersion = '2.1.0'
testImplementation "androidx.arch.core:core-testing:$archTestingVersion"

 

2. InstantTaskExecutorRule 추가하기
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()

 

3. LiveData Observing 하기 

원래 LiveData를 Observing하기 위해서는 LifecycleOwner(Activity나 fragment)가 필요합니다.

viewModel.resultLiveData.observe(fragment, Observer {
	// Observer code here
})

하지만 TasksViewModel에서는 activity나 fragment를 가질 수 없습니다.😭

이런 문제를 해결하기 위해 사용할 수 있는 것이 바로 ObserveForever 메서드입니다.

 

👇🏻 LiveData를 Observing하는 코드

@Test
fun addNewTask_setNewTaskEvent() {
    // 1. Given: a fresh TasksViewModel
    val tasksViewModel = TasksViewModel(
        ApplicationProvider.getApplicationContext()
    )

    val observer = Observer<Event<Unit>> {}
    try {
        // Observe the LiveData forever
        tasksViewModel.newTaskEvent.observeForever(observer)

        // 2. When: adding a new Task
        tasksViewModel.addNewTask()

        // 3. Then: the new task event is triggered
        val value = tasksViewModel.newTaskEvent.value
        assertThat(value?.getContentIfNotHandled(), (not(nullValue())))
    } finally {
        // Whatever happens, don't forget to remove the observer!
        tasksViewModel.newTaskEvent.removeObserver(observer)
    }

}

📌 ObserveForever

LifecycleOwner 필요 없이 LiveData를 영구적으로 Observing할 수 있도록 해주는 메서드입니다.

🌹 ObserveForever를 사용할 때는 반드시 Observer를 제거해주는 작업을 추가해주어야 합니다.

 

위의 코드를 보면 LiveData 하나를 Observing하는데 필요한 코드가 너무나도 많습니다.😅

이를 해결하기 위해 LiveDataTestUtil 클래스 내에 getOrAwaitValue라는 확장함수를 LiveData에 추가하여 이 Boilerplat를 제거해보록 하겠습니다.

 

7.2 LiveDataTestUtil.kt 클래스 추가하기

1. LiveDataTestUtil.kt 클래스를 test 디렉토리 하위에 만들어줍니다.

 

2. 코드 구현하기

먼저 LiveData의 getOrAwaitValue 확장함수를 확인해보도록 하겠습니다.

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUtil: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(t: T) {
            data = t
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }
    this.observeForever(observer)

    try {
        afterObserve.invoke()

        // Don't wait indefinitely if the LiveData is not set
        if(!latch.await(time, timeUtil)) {
            throw TimeoutException("LiveData value was never set")
        }
    } finally {
        this.removeObserver(observer)
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

위에서 작성한 LiveData를 Observing하기 위한 Boilerplat를 공통 확장함수로 빼둔 것이라는 것을 알 수 있습니다.

 

위 확장함수를 사용하면 아래와 같이 간단하게 테스트 코드를 작성할 수 있습니다.😆👍🏻

@Test
fun addNewTask_setNewTaskEvent() {
    // 1. Given: a fresh TasksViewModel
    val tasksViewModel = TasksViewModel(
        ApplicationProvider.getApplicationContext()
    )

    val value = tasksViewModel.newTaskEvent.getOrAwaitValue(
        afterObserve = tasksViewModel::addNewTask
    )
    assertThat(value.getContentIfNotHandled(), (not(nullValue())))
}

 

📌 여기서 잠깐!  getContentIfNotHandled란?

해당 메서드는 이 코드랩의 예제 프로젝트인 TO-DO app 내에서 직접 정의한 Event 클래스의 메서드입니다. 

/**
 * Returns the content and prevents its use again.
 */
fun getContentIfNotHandled(): T? {
    return if (hasBeenHandled) {
        null
    } else {
        hasBeenHandled = true
        content
    }
}

동일한 content에 대해 2번 이상 접근할 때는 Null을 반환하도록 하는 기능을 제공합니다.

 

7.3 테스트 실행해보기

테스트가 성공적으로 완료된 것을 볼 수 있습니다.

LiveData 하나 테스트하는데 많은 것을 학습한 기분이 드네요.ㅎㅎ😭

 

해당 코드랩을 완료한 이후에 flow로 테스트하는 것도 알아봐야 겠어요!

 


테스트관련 코드랩의 3번째 포스트였습니다.

이제 마지막 하나의 포스터만 작성하면 테스트 코드랩을 끝날 듯 해요.👍🏻

 

아직 개인적으로 학습해보고 있는 단계이기 때문에 혹시 틀린 점이 있거나 한다면

꼭!! 댓글로 말씀 부탁드립니다.😌

 

감사합니다. 🤗

반응형