본문 바로가기

Android/TDD

[Mockito] Spy Object를 사용하여 Unit Test를 해보자.

728x90

2022.02.24 - [Android/TDD] - [Mockito] Mockito를 사용하여 Unit Test 를 해보자 (feat. Truth + Junit4)

2022.02.25 - [Android/TDD] - [Mockito] Mockito를 사용해 Instrumented Unit Test를 해보자

 

필자가 이전에 Mockito를 사용하여 Test 작업을 했던 것들에 대한 글이 몇 개 존재한다.

해당 게시글에서는 아주 간단한 Sample Object를 사용하여, 아주 간단한 Test만 진행했었기 때문에 쉬운 난이도로 테스트를 진행해 볼 수 있었다.

 

하지만, 필자가 실제로 프로젝트에 Unit Test를 적용해보려고 하니 생각보다 정상적으로 되는 것이 많이 없었다.

Sample과 다르게 상속 받는 것들, 인자로 받는 것들 등 고려해야 할 요소들이 많았고, 그것들에 대하여 처리하는 방법에 대해서는 알아보지 않았고, 고려해야 할 사항에 대하여 생각을 깊게 하지 못했기 때문에 이러한 문제가 발생했다고 보았다.

 

문제를 해결하기 위하여 스터디를 진행하다 보니, 이러한 상황을 어느정도 해결할 수 있는 Spy Object에 대하여 알게 되었고 Spy를 사용하여 실제 프로젝트와 비슷한 환경에서의 Unit Test를 진행해보고자 한다.


그렇다면 처음으로,

Spy Object는 무엇인가?

실제 객체처럼 사용하기 위한 Object로, Stub 된 메서드를 제외하고 원래 객체의 행동을 수행한다.

 

즉,

Mock 객체로 만드는 경우 껍데기만 만들기 때문에 실제 동작을 수행할 수 없는 반면,

Spy 객체로 만드는 경우 실제 동작까지 수행할 수 있게 된다는 것이다.

 

간단한 예제를 사용하여 생각해보자.

 

@Before
fun setUpTest() {
    introActivity = Mockito.mock(IntroActivity::class.java)
    introViewModel = Mockito.mock(IntroViewModel::class.java)
}

 

이처럼 Mock객체를 만들고,

 

fun a() {
    b()
}

fun b() {
    
}

 

이렇게 메서드를 만들어 보자.

 

@Test
fun `sample test`() {
    introActivity.a()

    Mockito.verify(introActivity).b()
}

 

그리고 해당 테스트 코드를 실행시켰을 때,

a 메서드에서 b 메서드를 호출하기 때문에 정상적으로 테스트가 완료될 것이라고 생각하지만,

 

Wanted but not invoked:
introActivity.b();

 

와 같은 에러가 뜨면서 테스트에 실패하게 된다.

 

Mock 객체는 껍데기만 만들어두는 것이기 때문에, a()를 호출했을 때 b()를 호출하는 것이 아닌, 별 다른 동작을 하지 못하고 끝나기 때문이다.

 

따라서, 위와 같은 동작을 정상적으로 확인하기 위해서는 실제 객체를 만들어서 사용하는 방법도 있지만,

이번에는 spy object로 만들어서 사용해 보도록 하자.

 

@Before
    fun setUpTest() {
//        introActivity = Mockito.mock(IntroActivity::class.java)
        introActivity = Mockito.spy(Mockito.mock(IntroActivity::class.java))
    }

 

기존의 mock객체를 사용하지 않고, spy 객체로 만들어서 동일한 테스트 코드를 실행시켜 보면 정상적으로 동작하는 것을 확인할 수 있을 것이다.

 

여기서, 필자는 Mockito.spy(~) 부분에 mock객체를 다시 그대로 넣어주어서 사용하였는데,

IntroActivity가 BaseAcitivty를 상속받고 있기 때문에 해당 객체를 그대로 넣어주게 되면 BaseActivity에 대한 객체도 따로 생성해 주어야 한다.

이러한 작업을 하지 않기 위하여 해당 부분에 mock 객체를 그대로 넣었지만, 별다른 상속을 받지 않거나 다른 객체를 구현할 필요가 없는 경우에는 IntroActivity()처럼 새로운 객체를 생성해 넣어도 무관하다.

 

이런 방식을 실제로 사용한 부분을 가져와 보자.

 

@Before
fun `Test Setup`() {
    introRepo = Mockito.mock(AppVersionRepository::class.java)
    getAppVersionUseCase = GetAppVersionUseCase(introRepo)
    introViewModel = IntroViewModel(getAppVersionUseCase)
    introActivity = Mockito.spy(Mockito.mock(IntroActivity::class.java))
}

 

Clean Architecture 구조로 사용하고 있는 프로젝트에서 테스트 코드를 넣어 보았다.

activity에서 실제 메서드를 사용하기 위하여 해당 부분에는 spy 객체를 사용하도록 하였다.

 

private fun `AppVersion case Y`() {
    // Given : 특정 상황
    val version = AppVersionEntity("1.0.0", "Y", "")

    // When : 특정 액션
    introActivity.appUpdateFlow(version)

    // Then : 행동의 검증
    Mockito.verify(introActivity, never()).startNext()
}

 

그리고 간단하게 테스트 코드를 작성해보자.

appUpdateFlow에서는 AppVersionEntity 값 중 두 번째 인자로 사용하는 값이 N일 때만 startNext 메서드를 실행하도록 짜여져 있다.

그렇기 때문에, verify를 사용할 때 never()를 넣어주어 startNext가 실행되지 않음을 확인할 수 있도록 하였다.

 

이 부분에서 조금 더 확장해서 테스트 해볼 수 있는 부분은,

순차적인 함수의 실행을 확인하는 것이다.

따라서, 위에 작성했던 a(), b()를 사용하여 테스트해보도록 하자.

 

기본에는 Mockito.verify를 사용하여 해당 메서드가 호출되었는지를 확인하였다.

하지만, 해당 방법으로는 호출의 여부를 확인할 수는 있겠지만 순차적인 호출 여부는 확인할 수 없다.

 

@Test
fun `inOrder test`() {
    val inOrder = Mockito.inOrder(introActivity)

    introActivity.a()

    inOrder.verify(introActivity).b()
    inOrder.verify(introActivity).a()
}

 

따라서, Mockito.inOrder().verify를 사용하여 순차적인 호출을 확인해보도록 하자.

 

Verification in order failure
Wanted but not invoked:
introActivity.a();

Wanted anywhere AFTER following interaction:
introActivity.b();

 

a()에서 b()를 호출하도록 되어있는 구조이기 때문에, a > b 순서대로 메서드가 호출이 된다.

현재 위의 테스트코드에서는 b > a 순서로 호출을 확인하고 있기 때문에 위와 같은 에러가 호출된다.

 

inOrder.verify(introActivity).a()
inOrder.verify(introActivity).b()

 

그렇기 때문에 해당 코드의 순서를 위처럼 변경하게 되면, 정상적으로 테스트가 통과하게 된다.

 


기존 Mock객체를 사용하는 경우, Mockito.`when`().thenReturn 을 사용하여 변수나 함수의 리턴 값을 원하는 값으로 설정하여 테스트에 사용할 수 있다.

 

하지만, spy 객체로 사용하는 경우 해당 Mockito.`when`().thenReturn를 통해서 값을 변경할 수는 없다.

그렇다면 테스트를 위하여 객체의 return 값을 계속하여 변경해야하는가?

 

아니다.

 

Mockito.`when`().thenReturn 와 비슷한 형태인

Mockito.doReturn(value).`when`(spyObject).~ 형태로 사용하면 된다.

~에는 메서드나 변수가 들어가면 되고, value에는 지정한 메서드나 변수가 return 할 값을 설정해주면 된다.

 

a(), b()로 예를 들었던 함수에 c()에 대한 로직도 추가하여 확인해보도록 하자.

 

var check = true
fun a() {
    if (check) {
        b()
    } else {
        c()
    }
}

fun b() { }

fun c() { }

 

check는 true로 설정해두었기 때문에 단순히 a를 호출하게 되면 a > b가 호출되고 c는 호출이 되지 않는다.

 

여기서, 우리는 c가 호출되는 경우도 테스트가 하고 싶으므로 check에 대한 값을 false로 변경해보도록 하자.

 

@Test
fun `inOrder test`() {
    val inOrder = Mockito.inOrder(introActivity)
    Mockito.doReturn(false).`when`(introActivity).check

    introActivity.a()

    inOrder.verify(introActivity).a()
    inOrder.verify(introActivity).b()
}

 

위의 순차적인 함수 호출을 테스트한 테스트 코드에서 check 변수에 대한 doRetrun 절을 추가하였다.

introActivity에서 사용되는 check 변수에 대한 값을 false로 변경한다는 의미가 된다.

 

이렇게만 추가하고 테스트를 실행시켜보면, check에 대한 값이 false로 변경되어 테스트가 진행되기 때문에 b()에 대한 호출이 이루어지지 않아 에러가 발생하게 되고,

c()로 변경하게 된다면 a > c 순서대로 제대로 호출되어 해당 테스트가 통과하게 되는 것이다.

 

여기서 필자는 몇 가지 테스트를 더 해보았다.

보통 spy 객체를 사용하여 테스트하는 경우, 테스트할 때 호출되는 객체들이 존재해야지 정상적으로 동작하게 된다.

하지만 해당 객체들의 사용까지는 확인하고 싶지 않지만, 해당 객체를 호출하고 있는 메서드가 호출되는지 확인하고 싶은 경우에 doReturn을 사용하면 되지 않을까?라는 생각이 들었기 때문이다.

 

따라서, 위의 a, b, c 테스트에서 a함수를 호출하는데 b, c에 대한 함수 호출을 하지 않기 위해 stub을 추가해 주었다.

 

Mockito.doReturn("").`when`(introActivity).a()

 

하지만, a()는 아무런 값도 return을 하고 있지 않기 때문에 해당 코드가 추가되면 에러가 발생하게 된다.

 

'a' is a *void method* and it *cannot* be stubbed with a *return value*!
Voids are usually stubbed with Throwables:
doThrow(exception).when(mock).someVoidMethod();

If you need to set the void method to do nothing you can use:
doNothing().when(mock).someVoidMethod();

 

에러 내용을 토대로 doNothing을 사용해 보도록 하자.

 

@Test
    fun `doReturn test`() {
//        Mockito.doReturn("").`when`(introActivity).a()
        Mockito.doNothing().doThrow(RuntimeException()).`when`(introActivity).a()

        introActivity.a()

        Mockito.verify(introActivity).a()
        Mockito.verify(introActivity, never()).b()
        Mockito.verify(introActivity, never()).c()
    }

 

중간데 doThrow는 어디서 나왔는가 싶겠지만,

위의 에러 내용에서 확인한 것처럼 doNothing().when(mock).method() 로 작성하고 테스트를 해보니 doThrow를 사용해야 한다고 나와서 추가한 것이다.

 

doNothing()을 사용하면 해당 Method를 호출은 하지만, 아무런 동작은 하지 않겠다는 의미가 된다.

즉, 비어있는 a 메서드를 호출하는 것과 동일한 동작을 수행하게 된다.

 

위와 같이 doNothing 절을 추가해주고, verify에 never을 넣어서 b, c가 호출되지 않는 것을 확인하도록 해보자.

 

 

정상적으로 테스트가 통과하는 모습을 볼 수 있다.


테스트 코드를 작성하면 작성할수록 공부해야 하는 내용들이 증가하는 것 같다.

 

생각보다 실제 코드를 가지고 테스트하는 것에 제약이 많이 있고,

나는 사용하는 객체를 모두 선언해서 테스트한다고 생각하는데 어딘가 빠져있거나,

상속받은 클래스들을 따로 mock 객체로 만들어주는 작업을 해야 한다거나..

아직 익숙지 않아서 그런진 모르겠지만, 상당히 손이 많이 가고 시간이 많이 소요되는 것 같다.

 

gitHub에서 찾을 수 있는 다양한 테스트 샘플 코드를 보면 아직도 갈 길은 멀었다고 생각이 된다.

테스트 코드를 작성하면서 필요한 부분을 찾아 스터디를 하고, 어느 정도 정리가 된다면 다시 관련된 글을 작성해 볼 예정이다.

728x90