Android

Android) 테스트 코드 왜 작성 해야 할까? 예제로 알아보자

가짜 개발자 2021. 8. 29. 12:29

 


안드로이드에서 테스트 코드 "왜" 작성해야 할까?

  • 코드를 작성하여 기능을 구현하고, 그 기능이 제대로 작동하는지 에뮬레이터 혹은 디바이스에서 직접 결과를 정성스럽게 확인 -> 에러가 발생하면 로그를 찍어 파악 -> 다시 수정하고 테스트 반복. 
  • 위와 같은 방법으로 기능의 결함을 체크해도 문제없지 않을까? 하지만 이러한 작업은 규모가 작을 경우 문제 되지 않을 수 있지만, 앱의 규모가 커진다면 빌드하는 시간 + 테스트를 UI로 직접 입력하는 시간 등으로 시간이 점점 길어질 것입니다. 즉 테스트하기 위해 전체 앱을 매번 빌드하는 것은 비효율적인 작업이 되버립니다.
  • 그래서 테스트 코드는 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증신뢰성 높은 코드의 생산성을 높이기 위해 작성한다고 생각이 듭니다. 또한, 휴먼 리소스 낭비를 방지할 수 있습니다. 
  • 결론은 인간은 끊임없이 실수를 반복하기 때문에 테스트 코드가 이를 방지하는데 도움을 줄 수 있습니다.

 

 

 

테스트 코드 작성의 이점

  • 잘못된 부분을 빠르게 확인할 수 있게 해 준다 -> 안정성, 신뢰성 높아짐.
  • 디버깅 시간을 단축 -> 개발 시간 줄여줌.
  • 모듈이 의도대로 동작하고 있음을 확인 -> 리팩터링 시 부담 줄여줌.
  • 좋은 구조로 개발하게끔 도와줌 -> 테스트 코드를 작성하다 보면, 테스트 가능한 구조가 되도록 코드의 관심사를 분리하게 됩니다.

 

Android Test

  • Android는 기본적으로 JUnit을 기반으로, JVM에서 계측 테스트(Instrumentation Test), 로컬 단위 테스트(Unit Test)를 할 수 있습니다.

 

Unit Test

  • module-name/src/test/java/ 하위에 테스트 코드 작성
  • 빠르다
  • JVM에서 실행되는 테스트
  • 안드로이드 프레임워크와 종속성이 없거나 모의 객체를 생성할 수 있는 경우 이 테스트 사용
  • JUnit, Mockito, PowerMock, Truth,  Robolectric

 

 

Instrumentation Test

  • module-name/src/androidTest/java/ 하위에 테스트 코드 작성
  • 안드로이드 프레임워크에 종속성이 있는 테스트
  • 실제 안드로이드 기기나 에뮬레이터에서 실행되는 테스트
  • Espresso, UIAnimator, Robotium, Appium, Calabash

 

"무엇"을 테스트해야 할까?

테스트 코드 작성 범위

  • 수정, 변경되는 모든 기능 -> 코드의 변경으로 인해 영향받는 코드에 대한 테스트 코드 필수. 새로운 기능, 새로운 파일 추가, 기존 파일에 일부 동작이 추가된 경우 등.
  • 로직에 대한 검증은 필수 -> 사용자의 요청에 따라 올바른 로직의 수행 결과가 View에 올바르게 반영되는가 검증. 프로세스 간, 모듈 간, 클래스 간의 통용되는 주요 인터페이스에 대한 테스트, MVP 패턴을 적용한다면 Presenter의 로직, MVVM을 적용한다면 ViewModel의 로직 테스트 등.
  • View에 대한 테스트, 즉 UI Test는 필수적이지는 않다. -> 안드로이드 프레임워크나 UI 관련 테스트 코드는 작성 및 실행에 있어 어려움이 존재.
  • 뷰와 로직이 섞여 있는 경우 -> 테스트 코드를 작성하려면 최대한 관심사를 분리해야 합니다. 모듈 간 결합도가 낮아지도록 하는 DI, 디자인 패턴, 클린 아키텍처 등을 적용해서 테스트가 쉽게 이루어질 수 있도록 코드를 작성할 필요가 있습니다.
  • 그럼에도 어쩔 수 없이 섞여 있는 경우 -> View에 어쩔 수 없이 로직이 포함돼 있는 경우 Instrumented Test를 작성하거나 View로부터 로직을 분리하는 리팩터링 후 분리된 로직에 대한 테스트 코드 작성 방법이 추천.

 

테스트 작성 법칙

  • Given - 특정 상황이 주어지고. ex) 잘못 된 이메일을 입력한다.
  • When - (테스트하려는) 특정 액션이 발생했을 때. ex) 로그인 버튼을 클릭한다.
  • Then - 변화 된 상태나 수행되는 행동을 검증. ex) 이메일 검증 실패 메시지를 보여준다.
  • 아래 테스트 코드는 위의 법칙을 이용한 예제입니다.
@Test
fun 이메일로그인_실패케이스_잘못된이메일형식입력() {
    //Given
    잘못 된 이메일을 입력.
    
    //When
    이메일 로그인 버튼 클릭.
    
    //Then
    유효성 검증 실패 & 이메일 검증 오류 메시지 보여줌.
}

@Test
fun test_onClickEmailLogin_FailLogin_EnterIncorrectEmail() {
    `when`(view.getInputEmail()).thenReturn("abc.def@day")
    viewModel.onClickEmailLogin()
    verify(view).showMessageForIncorrectEmail()
}

 

 

안드로이드 Unit Test 작성의 어려움

  • 안드로이드는 Device 의존강한 프레임워크라 모든 앱은 안드로이드 OS가 설치된 디바이스 위에서 동작합니다. 그래서 JVM이 실행하는 안드로이드 Unit Test에서 안드로이드 컴포넌트들을 그대로 사용 또는 객체를 만들거나 할 때 문제가 발생합니다.(NPE 등등)
@Test
fun onCreate()  {
    val kakaoActivity = KakaoActivity()
    kakaoActivity.onCreate(null, null)
}
  • Unit Test를 수행할 때, 안드로이드 컴포넌트들은 Android SDK 경로에 있는 android.jar이 아닌 android-stubs-src.jar 파일을 참조하기 때문입니다. 해당 파일에 있는 클래스, 메서드들은 실제 구현이 없는 빈 껍데이기(Stub) 때문에 JUnit 상에서 정상적인 동작을 하지 못합니다.
  • 그래서 안드로이드 코드와 안드로이드 의존성이 없는 코드를 최대한 분리하는 방법을 적용해야 합니다. (Mocking, DI, MVVM, Clean Architecture 등)

 

 

유닛 테스트 코드 작성해보기

 

Mocking

  • 테스트를 위해 가짜 객체를 만드는 것. 안드로이드 종속성이 있는 컴포넌트를 테스트해야 한다면 모의 프레임워크를 사용하여 쉽게 테스트할 수 있습니다.
  • 안드로이드 종속성을 모의 개체로 대체하여 해당하는 종속성의 올바른 메서드가 호출되는지 확인하는 동안 나머지 안드로이드 시스템에서 단위 테스트를 격리할 수 있음. Java Mockito, PowerMock과 같은 라이브러리가 Mocking을 지원.
  • 아래의 코드에서 Context는 안드로이드에서 앱의 정보를 담고 있는 객체입니다. @Mock 어노테이션을 선언해 Mock Context 객체를 생성해 테스트할 수 있습니다.
// @RunWith - 지정된 클래스를 이용해 클래스 내의 테스트 메소드들을 수행하도록 지정, 
// MockitoJUnitRunner - 프레임워크 사용이 올바르고 모의 객체의 초기화를 단순화하는지 검증하도록 Mockito에 알림

private const val FAKE_STRING = "KaKao Application"

@RunWith(MockitoJUnitRunner::class)
class ExampleUnitTest {

    @Mock // 모의 객체 생성
    private lateinit var context: Context

    @Test
    fun readStringFromContext() {
        Mockito.`when`(context.getString(R.string.app_name)).thenReturn(FAKE_STRING)
        val result = context.getString(R.string.app_name)

        assertThat(result).isEqualTo(FAKE_STRING)
    }
}

 

Testing the Api Call (RxJava2)

  • RxJava2부터 사용할 수 있는 TestObserver, TestSubscriber를 이용한 API 통신 테스트 코드입니다.
  • TestObserver는 이벤트를 감지하고 연결된 테스트 체인에 따라 성공 여부를 알려줍니다. (onSubscribe, onNext, onError, onSuccess 등) 
class ApiTest {

    private val okHttpClient by lazy { NetworkModule.provideOkHttpClient() }
    private val retrofit by lazy { NetworkModule.provideRetrofit(okHttpClient) }
    private val daumService by lazy { ServiceModule.provideService(retrofit) }

    @Test
    fun testLoadImages() {
        daumService.loadImages("로제", 1, 30)
            .test() // Observable을 TestObserver로 변환.
            .assertSubscribed()  // 성공적으로 onSubscribe가 호출되었는지 검증.
            .assertValue { data -> data.documents.size == 30 } // value가 일치하는지 검증.
            .assertComplete() // 정상적으로 onComplete가 호출되었는지 검증.
            .assertNoErrors()    // 에러없이 끝났는지 검증.

    }

    @Test
    fun testNotFoundError() {
        daumService.loadImages(";", 1, 30)
            .test()
            .assertError { error ->
                // error를 HttpException으로 캐스팅 후 status code가 500인지 확인.
                error is HttpException && error.code() == 500
            }
    }
}

 

Testing the Repository (Mockito + RxJava2 + JUnit4 + Truth)

  • RxJava2의 테스트 메서드와 모의 객체를 생성해주는 Mockito 라이브러리와 가독성 있는 메서드를 제공해주는 Truth 라이브러리를 사용한 테스트 코드입니다.
  • 모의 객체를 생성해주는 방법은 여러 방법이 있습니다. 아래 코드에서 2 가지 방법으로 작성했습니다.
  • Mocking 한 클래스를 when() 안에서 실행할 함수를 호출하고thenReturn()에서 똑같이 호출 시 반환할 값을 명시해줍니다.
@RunWith(MockitoJUnitRunner::class)
class RepositoryTest {

    @get:Rule // Mockito를 사용한다는 규칙 설정
    var rule: MockitoRule = MockitoJUnit.rule() 

    @Rule
    @JvmField
    var instantTaskExecutorRule = InstantTaskExecutorRule() // 백그라운드 작업들을 같은 스레드에서 실행하여 테스트 결과를 동기적으로 실행되게 해줌

    @Mock
    lateinit var daumService: DaumService

    @Mock
    lateinit var daumRepository: DaumRepository (1)
    
    lateinit var daumRepository: DaumRepository (2)

    @Before
    fun setUp() {
        daumRepository = DaumRepositoryImpl(daumService) (1)
        daumRepository = Mockito.mock(DaumRepository::class.java) (2)
    }

    @Test
    fun `repository test`() {
        val documents = arrayListOf(
            Document(
                "2018-12-21T20:46:05.000+09:00",
                "티스토리",
                573,
                "http://t1.daumcdn.net/cfile/tistory/99AA874D5C1CCD7D12",
                1080
            )
        )
        val meta = Meta(false, 3998, 2857239)
        val list = ImageResponse(documents, meta)

        Mockito.`when`(daumRepository.loadImages("제니", 1, 1)).thenReturn(Single.just(list))

        daumRepository.loadImages("제니", 1, 1)
            .test()
            .awaitDone(3000, TimeUnit.MICROSECONDS) // interval() 메소드 처럼 비동기로 동작하는 Observable 코드 검증
            .assertOf {
                it.assertSubscribed()
                it.assertComplete()
                it.assertNoErrors()
                Assert.assertEquals(list.documents.size, it.values().size) // JUnit4의 검증 메소드
                assertThat(it.values()[0].documents.isNotEmpty()).isTrue() // Truth의 검증 메소드
            }

        daumRepository.loadImages("제니", 1, 1).toObservable()
            .observeOn(Schedulers.io())
            .subscribe {
                assertThat(it).isEqualTo(list)
            }
    }
}

 

 

Testing the Repository (RxJava2 + Mockk)

 

  • 위에서는 Mocking 방법으로 Java에서 주로 사용되는 Mockito 라이브러리를 사용했는데, 코틀린에서는 Mockk 라이브러리가 같은 기능을 해주어 Mockk 라이브러리를 사용한 테스트 코드입니다.
  • every() 메서드는 Mock 객체가 어떻게 동작할지 정의하는 함수입니다. Mockito의 when(method()). thenReturn(call)과 같은 기능을 합니다. returns 함수로 객체 반환, throws로 익셉션 발생, 반환 값이 없을 때 junt Runs, answer 등을 호출할 수 있습니다.

 

 

  • 아래 코드에서 Mock 객체를 생성해주고 every { }를 통해서 테스트를 진행하거나 every { }를 통해서 매번 Mock 처리를 하는 것이 번거로울 때, Mock 객체 생성에서 Relaxed Mock(2)을 사용하여 테스트 코드를 작성할 수 있습니다. 이때 every { }는 생략 가능합니다. 
  • 또한, mockk() 메서드를 통해 모의 객체를 만드는 방법과 @Mockk 어노테이션을 통해 모의 객체를 생성하는 방법도 있습니다.
@Test
    fun `repository test`() {
        val documents = arrayListOf(
            Document(
                "2018-12-21T20:46:05.000+09:00",
                "티스토리",
                573,
                "http://t1.daumcdn.net/cfile/tistory/99AA874D5C1CCD7D12",
                1080
            )
        )
        val meta = Meta(false, 3998, 2857239)
        val list = ImageResponse(documents, meta)

        val daumRepository: DaumRepository = mockk() (1) // Mock 객체 생성
        val daumRepository: DaumRepository = mockk(relaxed = true) (2)

        every { daumRepository.loadImages("제니", 1, 1) } returns Single.just(list) // 함수 호출 시 Mock 객체 retrun

        daumRepository.loadImages("제니", 1, 1) // 함수 호출

        verify { daumRepository.loadImages("제니", 1, 1) } // 함수를 실행 했는지 검증
    }

 

 

MVVM

  • 액티비티, 프래그먼트와 같은 UI 컴포넌트는 프레임워크 의존성이 강하기 때문에, 최대한 비즈니스 로직을 View Model에 작성하여 코드를 분리하여 작성합니다.

MVVM 패턴을 적용한 프로젝트에서 테스트 코드를 작성할 때 고려해야 할 점은 크게 아래와 같습니다.

 

  • View 사용자 이벤트 -> ViewModel의 함수 실행 -> Repository에 데이터 요청 (View는 이벤트 발생이 감지되면 단순히 ViewModel의 함수를 호출하기만 합니다.)
  • Repository에서 데이터가 넘어오면 ViewModel은 자신의 상태 변경 (View가 갱신되는 것은 ViewModel의 역할이 아닙니다. View는 알아서 ViewModel의 데이터를 관찰하고, 변경되면 최신 데이터로 갱신하고 ViewModel은 View가 처음 시킨 대로 Repository와 통신하고 자신의 상태만 변경합니다.)
  • 그래서 유닛 테스트를 통해 테스트할 관심 객체ViewModel이 되고, 검증할 것은 ViewModel의 동작인 관심 객체의 상태변화와 외부 객체 Repository의 함수 호출 여부가 되겠습니다.

 

Testing the ViewModel (RxJava2 + Mockk + JUnit4)

// ViewModel
fun fetchImages(query: String?, page: Int, size: Int) {
        addDisposable(
            daumRepository.loadImages(query, page, size)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({ image ->
                    if (image.documents.isNotEmpty()) {
                        _isEmptyList.value = false
                        _imageList.value = image
                    }
                    if (image.meta.total_count == 0) {
                        _isEmptyList.value = true
                    }
                }, { e ->
                    _isError.value = true
                    e.printStackTrace()
                })
        )
    }
  • RxAndroidAndroidSchedulers 클래스는 정적 필드 초기화 과정에서 안드로이드 프레임워크를 필요로 하기 때문에 종속성을 피할 방법이 필요합니다.
  • (1) 스케줄러를 외부에서 전달받도록 하는 인터페이스를 생성하고 ViewModel의 생성자 파라미터에 추가하기.
  • (2) RxJavaPlugins / RxAndroidPlugins 사용하기 - RxJavaPlugins는 런타임에 RxJava의 내부 핸들러를 덮어쓸 수 있는 메서드들을 제공하여 스케줄러를 상황에 따라 제어할 수 있게 해 줍니다.
  • 아래는 (2) 번 방법을 사용한 테스트 코드입니다. Schedulers.trampoline() 메서드는 새로운 스레드를 생성하지 않고, 대기행렬을 자동으로 만들어주어, 현재 스레드에서 이전 작업이 완료될 때까지 기다렸다가 다음 작업을 순차적으로 작업합니다. 그래서 유닛 테스트에서 비동기적으로 테스트를 진행할 수 있습니다.
class ViewModelTest {

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var viewModel: KakaoViewModel

    @MockK
    private lateinit var repository: DaumRepository

	// Schedulers.io()의 내부 핸들러를 Schedulers.trampoline()을 호출하도록 하여 하나의 스레드에서 순차적으로 코드 실행.
    @Before
    fun setUp() {
        RxJavaPlugins.setIoSchedulerHandler {
            Schedulers.trampoline()
        }
        
        RxAndroidPlugins.setInitMainThreadSchedulerHandler {
            Schedulers.trampoline()
        }

        MockKAnnotations.init(this, relaxed = true)
        viewModel = KakaoViewModel(repository)
    }

    @After
    fun tearDown() {
        RxJavaPlugins.reset()
        RxAndroidPlugins.reset()
    }

    @Test
    fun testViewModel() {
        val documents = arrayListOf(
            Document(
                "2018-12-21T20:46:05.000+09:00",
                "티스토리",
                573,
                "http://t1.daumcdn.net/cfile/tistory/99AA874D5C1CCD7D12",
                1080
            )
        )
        val meta = Meta(false, 3998, 2857239)
        val list = ImageResponse(documents, meta)

        every { repository.loadImages(any(), any(), any()) } returns Single.just(list)

        viewModel.fetchImages("제니", 1, 1)
        Assert.assertEquals(viewModel.imageList.value, list)
    }
}
  • 위의 방법은 테스트 파일마다 setUp(), tearDown()을 작성해줘야 하기 때문에 TestRule 클래스 구현을 통해 재사용성을 높이는 방법도 있습니다.
  • TestRule을 상속받아 base.evalute() 메서드 앞 뒤로 RxJava 스케줄러 코드를 추가하고, 테스트 코드에서 @Rule 어노테이션을 선언하여 위와 같은 테스트가 가능합니다.
class RxSchedulerRule: TestRule {
    override fun apply(base: Statement?, description: Description?): Statement {
        return object: Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
                RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() }
                RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
                RxJavaPlugins.setSingleSchedulerHandler { Schedulers.trampoline() }
                RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
                try {
                    base?.evaluate()
                } finally {
                    RxJavaPlugins.reset()
                    RxAndroidPlugins.reset()
                }
            }
        }
    }
}

// ViewModel Test
@Rule
@JvmField 
val rule : RxSchedulerRule = RxSchedulerRule()

 

Testing the ViewModel (Coroutines + Mockk + JUnit4)

  • 다음은 비동기 처리로 코루틴을 사용한 테스트 코드를 보겠습니다. Dispathcer.Main은 안드로이드의 Looper.getMainLooper() 메서드를 사용하여 UI 스레드에서 수행합니다. 따라서 유닛 테스트에서 바로 사용하지 못해 TestCoroutineDispatcher로 변경해야 합니다.
  • @ExperimentalCoroutinesApi
    class CoroutinesTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
        TestWatcher() {
        override fun starting(description: Description?) {
            super.starting(description)
            Dispatchers.setMain(testDispatcher)
        }
    
        override fun finished(description: Description?) {
            super.finished(description)
            Dispatchers.resetMain()
            testDispatcher.cleanupTestCoroutines()
        }
    }
  • 다음 TestRule로 CoroutinesTestRule을 지정해주고 runBlockingTest 스코프를 사용합니다. Rule에 의해 Dispatcher.Main이 TestCoroutinesDispatcher에서 동작하고 runBlockingTest는 코루틴이 Synchronously 하게 동작하도록 해줍니다. 그래서 비동기로 동작하는 코드고 해당 블록 안에서 동기적인 동작을 보장하므로 순차적으로 테스트가 가능합니다.
class ViewModelTest {

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    @ExperimentalCoroutinesApi
    @get:Rule
    val coroutinesTestRule = CoroutinesTestRule()

    private lateinit var viewModel: KakaoViewModel

    @MockK
    private lateinit var repository: DaumRepository

    @Before
    fun setUp() {
        MockKAnnotations.init(this, relaxed = true)
        viewModel = KakaoViewModel(repository)
    }

    @ExperimentalCoroutinesApi
    @Test
    fun `view model test`() = coroutinesTestRule.testDispatcher.runBlockingTest {
        val documents = arrayListOf(
            Document(
                "2018-12-21T20:46:05.000+09:00",
                "티스토리",
                573,
                "http://t1.daumcdn.net/cfile/tistory/99AA874D5C1CCD7D12",
                1080
            )
        )
        val meta = Meta(false, 3998, 2857239)
        val list = ImageResponse(documents, meta)

	// 코루틴을 사용한다면 co접두사가 붙은 메소드를 사용할 수 있습니다. 
        coEvery { repository.loadImages(any(), any(), any()) } returns list // repository.loadImages()가 어떤 매개변수로 호출되더라도 위의 list 값을 반환한다는 의미입니다.
        viewModel.fetchImages("제니", 1, 1)
        Assert.assertEquals(viewModel.imageList.value, list)
    }
}

 

 

+ Unit Test 네이밍 컨벤션

  • Test name should express a specific requirement (테스트 이름은 특정 요구 사항을 표현해야 한다)
  • Test name could include the expected input or state and the expected result for that input or state (테스트 이름에는 예상 입력 또는 상태와 해당 입력 또는 상태에 대한 예상 결과가 포함될 수 있다)
  • Test name should be presented as a statement or fact of life that expresses workflows and outputs (테스트 이름은 워크 플로와 출력을 표현하는 진술 또는 사실로 제시되어야 한다)
  • Test name could include the name of the tested method or class (테스트 이름에는 테스트된 메서드 또는 클래스의 이름이 포함될 수 있다)

 

 

 

 

결론 : 어렵지만 테스트 코드 작성을 생활화하자..


Preferences

Unit Testing on Android

RxJava 유닛테스트를 좀 더 쉽게할 수 있는 틈새 찾기

Mockk

TestObserver, TestScheduler

무엇을 테스트해야 하나요?

테스트 코드, 안드로이드에서 어떻게 작성해야 할까?

반응형