Android/테스팅

안드로이드 테스트 코드를 배워보자 (1) liveData 테스트, 비동기 테스트 기본

sh1mj1 2023. 10. 8. 20:06

난가..? ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

 

안드로이드 앱을 개발할 때 여러 기업에서, 프로젝트에서 테스트 코드를 작성하는 것은 중요하다고 말합니다.

아예 앱을 개발할 때 Test code 를 먼저 작성하는 경우도 있죠.

실제로 카카오에서 티스토리 앱을 개발하시는 개발자 분의 기술 글에서 테스트를 해야 하는 이유를 이렇게 말하고 있습니다. (기술 글 출처)

 

  • 개발 과정에서 문제를 미리 발견할 수 있다. 
  • 리팩토링을 안심하고 할 수 있다. 
  • 빠른 시간 내에 코드의 동작 방식과 결과를 확인할 수 있다. 
  • 좋은 테스트 코드를 연습하다 보면 자연스럽게 좋은 코드가 만들어진다. 
  • 의도한 대로 동작되는 것을 자신감 있게 말할 수 있다.
  • 애자일 방법론의 도입!

로버트 C. 마틴은 이렇게 말했습니다.

기술 실천 방법 없이 애자일을 도입하려는 시도는 실패할 수밖에 없다.”

로버트 C. 마틴이 말하는 기술 실천 방법

  • TDD(Test-Driven Development)
  • TDD 과정에서의 리팩터링
  • 단순한 설계
  • Pair 프로그래밍

이 밖에 객체지향의 관점에서도 Test code 는 중요합니다. 대학교 전공 수업에서도 테스트에 관련해서 배우고 중요하다는 것을 교수님이 잠깐 언급하셨지만 당시에 언급만 하고 넘어가셨고, 저는 실제 프로젝트를 진행하면서 TDD 나 테스트 코드를 많이 작성해보지도 않았습니다.... 

그래서! 기존, 그리고 앞으로의 프로젝트에 테스트 코드를 도입하기 위해 안드로이드 앱 개발에서 테스트 코드를 어떻게 사용하는지 코드ㄹ를 통해 알아보겠습니다.

 

테스트 관련 의존성 (build.gradle)

먼저 안드로이드에서의 테스트 관련 가장 기본 의존성들입니다.

testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

testImplementation "com.google.truth:truth:1.1.4"
androidTestImplementation "com.google.truth:truth:1.1.4"
  • 에스프레소: UI 관련 테스트를 위한 라이브러리
  • junit: 안드로이드 프레임워크에서 테스팅을 위한 확장 라이브러리 
  • truth : 테스트 단언문(이하 Assertion)과 실패 메시지(Failure Message)의 가독성을 향상시켜 줄 수 있는 라이브러리

이것부터 먼저 추가해줍시다. 만약 테스트 코드에 익숙하신 분들은 여러 라이브러리가 빠져있다고 생각하실 텐데 글을 진행하면서 아래에서 더 추가될 것입니다.

 

Test 의 종류

안드로이드 프로젝트를 만들면 항상 아래와 같은 패키지가 생성되었습니다.

여기서 androdiTest 와 test 의 차이는 이렇습니다.

  • (androidTest)
    • 안드로이드 프레임워크의 컴포넌트를 사용해서 테스트해야 하는 테스트 코드들이 작성됨
    • 예뮬레이터 또는 타겟에서 동작시키는 테스트 코드로, 앞선 글에서 설명한 Integrated Test 코드들이 여기 작성됨.
  • (test)
    • 안드로이드 프레임워크의 컴포넌트와 의존성이 없는 테스트 코드들을 작성하는 곳.
    • 에뮬레이터가 아닌 JVM에서 테스트 할 수 있는 코드들이다.

 

위에 build.gradle 에서 `testImplementation`와 `androidTestImplementation` 가 나왔죠?

당연히 `testImplementation` 가 (test) 관련 라이브러리, `androidTestImplementation`  가 (androidTest) 관련 라이브러리입니다. 그러니 에스프레소 라이브러리는 `androidTestImplementation` 을 이용하여 의존성을 추가합니다.

 

가장 기본적인 테스트 코드

먼저 프로젝트를 생성하면 기본적으로 `ExampleUnitTest` 라는 클래스에 테스트가 추가됩니다.

여기서 expected 는 기댓값이고 actual 은 실제값입니다.

JUnit 을 이용한 테스팅은 기본적으로 위처럼 `assertXXX` 함수를 이용해서 기댓값과 실제값을 비교해서 테스팅 통과 여부를 정합니다.

 

해당 함수만 실행(run Test) 해보면 아래처럼 결과가 나옵니다.

만약 `assertEqauls(4, 2+3)` 코드로 변경하고 돌린다면 아래처럼 됩니다.

 

함수를 만들어서 테스트 해보자(회원가입)

이제 함수 시그니처를 직접 만들어 봅시다.  클래스는 사용자 등록(회원가입) 관련한 클래스입니다.

그리고 함수는 사용자의 id, 비번, 비번 확인, 사용자 이름, 나이를 패러미터로 받으며 유효성 검사를 하는 함수입니다. (코드 참조)

class UserRegistrationInput {
    fun validateRegistrationInput(
        id: String,
        password: String,
        confirmedPassword: String,
        username: String,
        age: Int
    ): Boolean {
        return true
    }
}

일단 메서드 내부에는 어떤 입력을 주던지 항상 true 를 리턴하도록 합시다.

 

그리고 (test) 패키지에서 클래스를 만들어 테스트 코드를 작성해봅시다.

`UserRegistrationInput` 클래스에서 cmd + N 으로 테스트 코드 클래스를 쉽게 만들 수 있습니다.

 

import com.google.common.truth.Truth.*
import org.junit.Assert.assertEquals
import org.junit.Test

class UserRegistrationInputTest {
    @Test
    fun `Empty id returns false`() {
        val result = UserRegistrationInput.validateRegistrationInput(
            "",
            "123",
            "123",
            "John",
            20
        )
        assert(!result) // // kotlin 이 제공하는 테스트
        assertEquals(false, result) // Junit 이 제공하는 테스트
        assertThat(result).isFalse()
    }

    @Test
    fun `유효한 id & 올바르게 반복된 비번이면 true`() {
        val result = UserRegistrationInput.validateRegistrationInput(
            "id",
            "123",
            "123",
            "John",
            20
        )
        assert(result)
        assertEquals(true, result)
        assertThat(result).isTrue()
    }
}

위 코드로 가장 먼저 알 수 있는 점이 있습니다.

Names for test methods In tests (and only in tests), you can use method names with spaces enclosed in backticks.

  • 테스트 코드에서 함수 이름은 백틱(`) 으로 감싸서 문장으로 만들 수 있다. 그 문장은 한글도 된다.
  • 실제 비즈니스 로직에서는 이렇게 사용이 불가능하지만 테스트 코드에서는 위처럼 더 직관적으로 사용할 수 있다.

코틀린 코딩 컨벤션에는 이러할 때의 기준이 설명되어 있습니다. (테스트 코드 함수 이름 코틀린 컨벤션 글)

 

그리고 세가지 종류의 assert 가 있습니다. 

`assert(!result)` : 코틀린이 자체적으로 제공하는 테스트 단언문

`assertEquals(false, result)` : `Junit` 이 제공하는 테스트 단언문

`assertThat(result).isFalse()`: `Truth` 라이브러리가 제공하는 테스트 단언문.

 

첫번째 단언문보다 Junit 의 단언문이 더 가독성이 좋고, 또 그것보다는 Truth 의 단언문이 더 가독성이 좋아보입니다.

 

위와 같이 2개 정도 정상적으로 로직이 동작하면 통과해야하는 테스트들을 작성했습니다.  지금은 해당 함수의 로직이 구현되지 않았으니 테스트들을 실패할 겁니다.

 

첫번째 테스트 메서드는 id 가 비어있으니 원래는 `false` 가 나와야 할 것입니다. (우리는 결과가 `false`일 것이라고 예상함)

그래서 `false` 가 나올 것이라고 작성했지만, 실제로는 `true` 가 나와서 실패합니다. 그래서 `assert` 에서 예외를 던집니다.

이렇게 테스트에 실패하면 관련 내용도 친절히 알려주고 있습니다. 일반적으로 예외를 던질 때도 그렇듯이 여러 assertion 이 있어도 중간에 예외가 던져지면 해당 코드 아래의 단언문은 실행되지 않습니다.

 

두번째 테스트 함수는 예상대로 `true` 가 뜨지만, 사실 로직에서 항상 true 를 리턴하도록 해서 `true` 인 것입니다. 당연한 말이지만 어떤 로직이 제대로 동작하는지 보려면, 제대로 동작할 경우의 조건에서의 (제대로 된 패러미터 등) 테스트 결과도 확인해보아야 하고, 그렇지 않을 경우의 테스트 결과도 확인해보아야 합니다. 

위에서 만약 두번째 함수만 테스트해보고 올바르게 동작한다고 생각하면 안된다는 것이죠!

 

이제 `UserRegistrationInput` 클래스에서 로직을 아주 간단히 만들어봅시다.

class UserRegistrationInput {

    val existedID = listOf("existingID1", "existingID2")

    fun validateRegistrationInput(
        id: String,
        password: String,
        confirmedPassword: String,
        username: String,
        age: Int
    ): Boolean {
        if (id.isEmpty() || password.isEmpty()
            || confirmedPassword.isEmpty() || username.isEmpty())
            return false

        if (id in existedID) return false
        if (password != confirmedPassword) return false
        if (age <= 0) return false

        return true
    }

이렇게 로직을 작성해주고 바로 동일한 테스트를 돌려보면 아래처럼 결과가 나옵니다.

`Empty id returns false' 메서드  → Test passed

`유효한 id & 올바르게 반복된 비번이면 true` 메서드 -> Test passed

 

이런 식으로 테스트를 하면 됩니다.

 

그렇다면 이제 ViewModel, LiveData, Mockito, Junit, Truth 를 사용한 테스트를 만들어봅시다.

ViewModel + LiveData, Mockito 를 사용한 테스트

위 `UserRegistrationInput` 와 테스트를 활용하겠습니다.

안드로이드 앱에서는 ViewModel 이 로직과 값을 갖고 있는 클래스이기 때문에 실제 안드로이드 테스트도 가장 활발하게 일어나고, 중요합니다. ViewModel 의 함수가 호출된 후에 LiveData 에 값이 잘 세팅되는지를 테스팅하죠

 

그런데 LiveData 를 테스팅하려면 의존성을 추가해야 합니다.

testImplementation "androidx.arch.core:core-testing:2.2.0"

 

`UserRegistrationInput` 에 간단한 함수를 두개 추가해줍시다.

fun recommendIdPlus123(id: String): String {
    return id + "123"
}

fun recommendIdPlusId(id: String): String {
    return id + id
}

만약 어떤 id 를 입력했는데 중복이어서 새로운 id 를 추가해주는 함수입니다. 실제로는 여러 복잡한 로직이 있겠지만 공부를 위함이므로 간단히 뒤에 숫자 123 을 붙이는 함수와 id 를 두 번 써서 붙이는 함수로 만들었습니다.

 

그리고 `UserRegistrationViewModel` 을 만들었습니다.

class UserRegistrationViewModel(private val userRegistrationInput: UserRegistrationInput) {

    var _recommendIdPlus123 = MutableLiveData<String>()
    val recommendIdPlus123: LiveData<String>
        get() = _recommendIdPlus123

    var _recommendIdPlusId = MutableLiveData<String>()
    val recommendIdPlusId: LiveData<String>
        get() = _recommendIdPlusId

    fun recommendIdPlus123(id: String) {
        val recommendedId = userRegistrationInput.recommendIdPlus123(id)
        _recommendIdPlus123.value = recommendedId
    }

    fun recommendIdPlusId(id: String) {
        val recommendedId = userRegistrationInput.recommendIdPlusId(id)
        _recommendIdPlusId.value = recommendedId
    }
}

 

`UserRegistrationInput` 을 프로퍼티로 받도록 해서 해당 함수를 사용하도록 만들었습니다.

 

그리고 테스트 코드를 아래처럼 작성합니다.

`UserRegistrationViewModelTest`

class UserRegistrationViewModelTest {

    lateinit var userRegistrationViewModel: UserRegistrationViewModel
    lateinit var userRegistrationInput: UserRegistrationInput

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    @Before
    fun setUp() {
        userRegistrationInput = Mockito.mock(UserRegistrationInput::class.java)
        Mockito.`when`(userRegistrationInput.recommendIdPlus123("ExampleId")).thenReturn("ExampleId123")
        Mockito.`when`(userRegistrationInput.recommendIdPlusId("ExampleId")).thenReturn("ExampleIdExampleId")
        userRegistrationViewModel = UserRegistrationViewModel(userRegistrationInput)
    }

    @Test
    fun recommendIdPlus123_idSent_updateLiveData() {
        userRegistrationViewModel.recommendIdPlus123("ExampleId")
        val result = userRegistrationViewModel.recommendIdPlus123.value
        assertThat(result).isEqualTo("ExampleId123")
    }

    @Test
    fun recommendIdPlusId_idSent_updateLiveData() {
        userRegistrationViewModel.recommendIdPlusId("ExampleId")
        val result = userRegistrationViewModel.recommendIdPlusId.value
        assertThat(result).isEqualTo("ExampleIdExampleId")
    }

}

이전 단계에서 짰던 테스트 코드에서 갑자기 무언가 많이 추가되었죠? 천천히 하나씩 봅시다.

 

InstantTaskExecutorRule 이 뭔데?

먼저 `get:Rule` 쪽부터 설명하겠습니다.

규칙은 모든 작업들을 동기적(synchronous)으로 실행되도록 하는 것입니다. 테스트 시에도 우리가 따로 동기화에 신경쓰지 않아도 되는 것이죠.

안드로이드 공식 개발자 문서에서는 아래처럼 말합니다.

A JUnit Test Rule that swaps the background executor used by the Architecture Components with a different one which executes each task synchronously.You can use this rule for your host side tests that use Architecture Components.
(번역) Architecture Components 에서 사용하는 백그라운드 실행기를 각 작업을 동기적으로 실행하는 다른 실행기로 교체하는 JUnit 테스트 규칙이다. Architecture Components 를 사용하는 호스트 사이드 테스트에 이 규칙을 사용할 수 있다.

 

여기서 호스트 사이드는 그냥 개발을 진행하는 컴퓨터나 테스트 환경을 말합니다. 디바이스(실제 안드로이드 폰) 사이드와 대조되는 거에요.

이 규칙을 정의하면 해당 클래스에서 발생하는 모든 Architectur Component 의 백그라운드 실행을 하나의 스레드에서 작동시켜줘서 스레드 세이프한 상태에서 테스트를 동작시킬 수 있습니다.

 

이 규칙은 `LiveData` 를 테스트할 때는 필수적으로 사용해야 합니다!

기본적으로 테스트는 메인 스레드가 아닌 다른 스레드에서 실행되기 때문입니다.

 

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

 

내부에서는 `ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() {`  와 `isMainThread() { return true;}`   부분에 주목해서 봅시다.

`ArchTaskExecutor` 가 새로운 `TaskExecutor` 를 생성하면서 `isMainThread` 메서드를 강제로 `true` 로 리턴하고 있습니다. 

 

Mockito 는 뭔데 (Test Double)

모키토는 Test Double 중 하나입니다. 테스트 더블은 아래처럼 대략적으로 5가지가 있습니다.

간단히 설명하면 이렇습니다.

  • Dummy 객체는 테스트에서 특정 매개 변수로 전달되지만 실제로 사용되지는 않는 객체. 패러미터를 단순히 채우기 위해서 사용하고 관심사가 아님.
  • Stub 객체는 메서드 호출에 대해 하드 코딩된 결과를 제공하는 가짜 객체. 메서드가 호출되면 미리 정의된 결과를 리턴하도록 구성함.
    테스트에서 호출된 요청에 대해서 미리 준비해둔 결과를 제공함.
  • Spy 객체는 실제 객체의 일부 메서드를 실제로 호출하고, 일부 메서드는 스텁, mock 하는 방법으로 작동함.
  • Mock 객체는 실제 객체의 동작을 시뮬레이션하고, 특정 메서드 호출에 대한 응답을 지정할 수 있는 가짜 객체. 테스트 시나리오에 따라 메서드 호출을 감지하고 원하는 결과를 리턴하거나 예외를 던질 수 있음. 
    우리는 호출에 대한 기대를 적고, 해당 내용에 따라 동작하도록 테스팅함.
  • Fake 객체는 실제 객체의 대체물로 사용됨. 실제 동작을 구현하지만, 실제 구현보다 더 간단하고 빠르게 함.
    예를 들어 로컬 DB 에서 가져오는 동작을 메모리 DB 를 사용하도록 하는 등...

아래는 각 사용에서의 예시 코드입니다. 필요한 분만 보세요

더보기

테스트 더블 예시 코드

1. Mock (모의 객체) 예시:

// 모의 객체 생성
val mockUserService = mock(UserService::class.java)

// 모의 객체 설정 - 특정 메서드 호출에 대한 결과 설정
`when`(mockUserService.getUserById(1)).thenReturn(User(id = 1, name = "John"))

// 테스트 대상 코드에서 모의 객체 사용
val userManager = UserManager(mockUserService)
val user = userManager.getUserDetails(1)

// 검증 - 특정 메서드가 호출되었는지 확인
verify(mockUserService).getUserById(1)

2. Stub (스텁) 예시:

// 스텁 객체 생성
val stubDatabase = StubDatabase()

// 스텁 설정 - 메서드 호출에 대한 하드코딩된 결과 제공
stubDatabase.addData("123", "Alice")
stubDatabase.addData("456", "Bob")

// 테스트 대상 코드에서 스텁 객체 사용
val userService = UserService(stubDatabase)
val userName = userService.getUserName("123")

// 검증 - 원하는 결과 반환 확인
assertEquals("Alice", userName)

3. Spy (스파이) 예시:

// 실제 객체 생성
val realCalculator = Calculator()

// 스파이 객체 생성 및 설정 - 일부 메서드 스파이링
val spyCalculator = spy(realCalculator)
doReturn(10).`when`(spyCalculator).add(5, 5)

// 테스트 대상 코드에서 스파이 객체 사용
val result = spyCalculator.add(5, 5)

// 검증 - 일부 메서드가 실제로 호출되었음을 확인
verify(spyCalculator).add(5, 5)
assertEquals(10, result)

4. Fake (페이크) 예시:

// 페이크 객체 생성
val fakeDatabase = InMemoryDatabase()

// 테스트 대상 코드에서 페이크 객체 사용
val userRepository = UserRepository(fakeDatabase)
userRepository.saveUser(User(id = 1, name = "Alice"))

// 검증 - 실제 데이터베이스 대신 메모리 내 데이터베이스 사용
val savedUser = fakeDatabase.getUserById(1)
assertEquals("Alice", savedUser?.name)

5. Dummy (더미) 예시:

// 더미 객체 생성 - 단순한 객체 생성
val dummyLogger = DummyLogger()

// 테스트 대상 코드에서 더미 객체 사용
val dataProcessor = DataProcessor(dummyLogger)
dataProcessor.processData("Sample Data")

// 검증 - 더미 객체는 아무 작업도 수행하지 않음
// 더미 객체는 특별한 검증이 필요하지 않을 때 사용됩니다.

 

테스트 돌려보기 - 에러 마주침...

다시 돌아와서 위 코드처럼 테스트를 돌려봤습니다. 그런데 에러가 떴습니다.

Could not initialize plugin: interface org.mockito.plugins.MockMaker (alternate: null)
java.lang.IllegalStateException: Could not initialize plugin: interface org.mockito.plugins.MockMaker (alternate: null)
...
Caused by: java.lang.NoClassDefFoundError: net/bytebuddy/dynamic/loading/ClassInjector$UsingReflection
...

`bytebuddy` 와 `ClassInjector` 가 not found 라고 합니다.

여기서는 `bytebuddy` 와 `objenesis` 를 내부적으로 사용하기 때문에 추가로 설정을 해주어야 한다고 합니다. (참고 블로그 글)

그래서 제가 참고한 블로그에서는 byte-buddy-1.10.22 와 objenesis-3.2 를 추가로 프로젝트 dependencies 에 추가해주고 다시 실행했다고 합니다. 

 

그런데 이것보다 더 좋은 방법이 있습니다.

mockito-core 를 직접 build.gradle 에서 dependency 를 최신 버전으로 추가해 주면 됩니다.

testImplementation 'org.mockito:mockito-core:4.2.0'

이렇게 넣어주고 sync 하면 gradle 에 아래와 같이 생깁니다.

기존에 안드로이드  프로젝트를 생성하면 자동으로 들어가는 모키토는 옛날 버전입니다. 그래서 직접 비교적 최신 버전을 넣어주면 간단히 해결됩니다.

 

그리고 나서도 문제가 또 발생합니다. 아래와 같은 에러가 발생합니다.

when() requires an argument which has to be 'a method call on a mock'.
For example:
    when(mock.getArticles()).thenReturn(articles);

Also, this error might show up because:
1. you stub either of: final/private/equals()/hashCode() methods.
   Those methods *cannot* be stubbed/verified.
   Mocking methods declared on non-public parent classes is not supported.
2. inside when() you don't call method on mock but on some other object.

org.mockito.exceptions.misusing.MissingMethodInvocationException: 
when() requires an argument which has to be 'a method call on a mock'.
For example:
    when(mock.getArticles()).thenReturn(articles);

mock 객체를 잘 만들어서 해결했는데도 에러가 뜨는 이유는 테스트할 때 사용하는 클래스와 메서드가 `final` 이기 때문입니다.

제가 '코틀린 인 액션' 을 보며 공부했을 때 자바와 코틀린 클래스, 메서드의 차이였습니다.

  • java
    • 클래스나 메서드에 `final` 키워드를 안 붙이면 기본적으로 다른 클래스에서 상속이 가능한 클래스가 되고, 오버라이딩이 가능한 메서드가 됨.
  • kotlin
    • 클래스와 메서드가 따로 키워드를 안 붙여도 `final` 이 됨.
    • 만약 상속 가능, 오버라이딩 가능하려면 `open` 키워드를 붙여야 함.

즉, 아래처럼 해야 하죠.

open class UserRegistrationViewModel(private val userRegistrationInput: UserRegistrationInput) {
		...
    open fun recommendIdPlus123(id: String) {
       ...
    }

    open fun recommendIdPlusId(id: String) {
       ...
    }
}
open class UserRegistrationInput {
		...
    open fun recommendIdPlus123(id: String): String {
				...
    }

    open fun recommendIdPlusId(id: String): String {
        ...
    }
}

이렇게 하면 드디어 테스트가 됩니다. 혹은 인터페이스를 사용하는 것으로도 해결이 가능합니다.

 

그런데 이렇게 하나하나 `open` 으로 만드는 것은 너무 귀찮고 비효율적입니다.

다행히 Mockito 2 버전 부터는 이에 대해서 간단히 할 수 있게 지원합니다. ( 관련 글 )

그를 위해서는 설정을 추가해주어야 하는데, 관련 글에서 안내한 대로 프로젝트 단위에서 src/test/resources 하위에 mockito-extensions 이라는 이름의 디렉토리를 생성하고, `org.mockito.plugins.MockMaker` 라는 이름의 파일을 생성해주어야 합니다. 그 이후에는 `mock-maker-inline` 이라고 작성해주기만 하면 됩니다.

이렇게 수정하면 테스트에서 사용하는 클래스와 메서드가 `open` 이 아니어도 테스트가 가능해지는 것을 볼 수 있습니다.

 

ViewModel, LiveData, getOrAwaitValue 를 사용한 비동기 테스트

이번에는 위에서 사용했던 클래스 말고 다른 클래스를 사용해보겠습니다. (관련 코드는 여기서 가져왔습니다.)

코드는 아래와 같습니다.

class MyViewModel {
    private val _liveData1 = MutableLiveData<String>()
    val liveData1: LiveData<String> = _liveData1

    // [Transformations.map] 사용해서 값을  대문자로 변환.
    val liveData2 = _liveData1.map { it.uppercase(Locale.ROOT) }

    fun setNewValue(newValue: String) {
        _liveData1.value = newValue
    }
}

`liveData1` 은 `_liveData` 와 바로 연결되어 있고 `liveData2`는 `Transformation.map`이라는 함수를 사용해서 삾을 대문자로 변환하고 있습니다. 관련해서 테스트 코드를 만들어봅시다.

class MyViewModelTest {

    private lateinit var myViewModel: MyViewModel

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    @Before
    fun setUp() {
        myViewModel = MyViewModel()
    }

    @Test
    fun isLiveDataEmitting() {
        myViewModel.setNewValue("foo")
        assertThat(myViewModel.liveData1.value).isEqualTo("foo") // 테스트 성공
        assertThat(myViewModel.liveData2.value).isEqualTo("FOO") // 테스트 실패
    }
}

테스트를 해보면 `liveData1` 에는 데이터가 잘 반영되지만 `liveData2`는 데이터가 반영되지 않아서 `null` 값인 것을 볼 수 있습니다.

`liveData` 는 필요한 것까지만 작업을 수행합니다. `liveData2.value` 를 읽어도 `liveData2` 는 observing되는 것이 아니니까 관련된 변환의 chain(연쇄)가 시작되지 않습니다. 오직 `observe` 를 통한 구독만이 이를 수행하고 변환의 결과는 observing 되지 않는다면 계산되지 않습니다. 아래 그림처럼 말이죠

`liveData1` 의 값은 `mutableLiveData` 라서 변환을 계산할 필요가 없어 값을 읽을 수 있지만 `liveData2` 는 그렇지 않습니다.

 

우리의 의도대로 한다면 `liveData2` 도 변환이 되어서 `Foo` 가 되어야 합니다.

이에 대해서는 아래처럼 코드를 작성하면 됩니다.

@Test
fun isLiveDataEmitting_observeForever() {
    myViewModel.setNewValue("foo")
    myViewModel.liveData2.observeForever { }
    
    assertThat(myViewModel.liveData1.value).isEqualTo("foo") // 테스트 성공
    assertThat(myViewModel.liveData2.value).isEqualTo("FOO") // 테스트 성공
}

이렇게 하면 매우 간단하게 우리의 의도대로 테스트할 수 있습니다.

하지만 좋은 방법은 아니죠. 만약 우리가 여러 liveData 들을 observing 해야 할 때 불필요한 반복 코드가 생깁니다. 

여기에 대해서 안드로이드 개발자 문서의 colab 에서는 아래와 같은 방법을 추천합니다. (구글 코랩 문서)

 

LiveDataTestUtil.kt

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

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

    try {
        afterObserve.invoke()
        
        // LiveData 가 설정되어 있다면 무한히 기다리지 않기
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }

    } finally {
        this.removeObserver(observer)
    }

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

안드로이드 Jetpack 라이브러리에서는 아직 테스트 헬퍼를 따로 제공하지 않습니다. (2023.10.8 에 안드로이드 코랩을 확인해보니 그렇네요)

`LiveDataTestUtil.kt ` 라는 파일을 만들고 최상단 함수로 위와 같이 생성합니다. 이 함수는 아래와 같은 동작을 하고 있습니다.

  • onChanged 를 통해서 새 값을 받을 때까지 LiveData 를 관찰한 다음에 observer를 제거함.
  • liveData 는 이미 값이 있으면 즉시 리턴함.
  • 값이 설정되지 않은 경우 2초(내가 설정한 값 time: Long = 2) 후에 예외 발생.
  • 이렇게하면 문제가 발생했을 때 테스트가 완료되지 않는 경우 방지 가능함.

이렇게 한 후에 아래처럼 테스트하면 정상적으로 테스트 됩니다.

fun isLiveDataEmitting_getOrAwaitValue() {
        myViewModel.setNewValue("foo")

        // Pass:
        assertThat(myViewModel.liveData1.getOrAwaitValue()).isEqualTo("foo")
        assertThat(myViewModel.liveData2.getOrAwaitValue()).isEqualTo("FOO")
    }

코드 상에서 반복될 가능성이 있는 `observeForever` 라는 코드도 없앨 수 있고, 또 만약 개발자가 `muViewModel.setNewValue("foo)` 와 같은 메서드를 작성하는 것을 잊어버린다면 예외를 던져서 알려주게 됩니다.

구조는 아래처럼 될 것입니다.

마지막으로 여기서 `await` 함수가 하는 역할에 대해서만 간단히 알아보고 다음 글로 넘어가겠습니다.

  • 스레드가 `CountDownLatch` 의 카운트가 0이 될 때까지 대기하는데 사용되는 메서드임.
  • `await` 가 예외를 던질 수 있으므로 try-catch 블록으로 래핑해야 함.
  • `await` 메서드는 래치의 카운트가 0이 될 때까지 무한히 대기하므로 카운트가 0이 되기 전까지 현재 스레드가 블록됨.
  • `await(long timeout, TimeUnit unit)`  메서드를 사용하면 특정 시간동안만 대기하고 시간이 경과하면 예외를 던지도록 설정할 수 있음. 이것으로 무한히 대기하는 것을 피할 수 있음.
  • 만약 다른 스레드에서 `CountDownLatch` 의 `countDown()` 메서드를 호출해서 카운트를 감소시키면, `await()` 메서드에서 대기 중인 스레드는 카운트가 0이 될 때까지 대기함. 이후에는 대기 중인 스레드가 해제되고 실행을 계속할 수 있음.

 

 

 

Reference Link

https://tech.kakao.com/2021/11/08/test-code/

https://velog.io/@cmplxn/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8C%85-%EC%9E%85%EB%AC%B8%ED%95%98%EA%B8%B0

https://sonseungha.tistory.com/633

medium.com/@stefanovskyi/unit-test-naming-conventions-dd9208eadbea

https://github.com/mockito/mockito/wiki/What's-new-in-Mockito-2#unmockable

https://medium.com/androiddevelopers/unit-testing-livedata-and-other-common-observability-problems-bb477262eb04

https://developer.android.com/codelabs/advanced-android-kotlin-training-testing-basics#8