tools & libs/단위테스트(junit, spock)

[spock]가짜 객체를 이용한 테스팅 - 2

  • -
반응형

Mock

 

Mock이란?

Mock은 Stub과 마찬가지로 테스트 과정에서 사용되는 가상의 객체이다. Mock은 메서드에서 수신하는 호출을 등록해서 실제 호출이 잘 되었는지 확인하는 용도로 사용된다.

위와 같은 상황에서 service의 save가 호출되면 repo의 save가 호출되고 repo는 insert query를 수행해서 DB에 반영한다.

public class ScoreServiceImpl {
    . . .
    public void save(String name, int score) {
        repo.save(name, score);
        printScore(name);
    }

    public void printScore(String name) {
        System.out.println(name + "의 점수는 " + repo.getScore(name));
    }
}

하지만 Stub에서와 마찬가지로 repo가 테스트 대상이 아니라 Service가 테스트 대상이므로 실제 DB에 저장되는가가 궁금한 것은 아니고 Service의 입장에서는 단지 repo가 호출되는지 궁금한 상황이다. 이때 repo의 save는 void를 반환하므로 Stub에서 처럼 값을 넘겨서 확인할 수도 없다. 

우리는 어떻게 repo의 save가 정상적으로 호출됨을 알 수 있을까?

Mock의 생성과 간단한 사용

Mock  객체를 생성할 때는 Mock() 생성자를 이용한다.

    // 공통 객체 선언
    ScoreRepository repo = Mock()
    @Subject
    ScoreServiceImpl service = new ScoreServiceImpl(repo)

    def "repo의 메서드들은 잘 호출되고 있는가?"() {
        when:
        service.save("hong", 30)

        then:
        1 * repo.save(_, _)
        1 * repo.getScore(_)
    }

ScoreRepository에는 가짜 객체인 Mock이 할당되어있다.

test feature의 when 블록에서 service의 save를 호출하고 then 블록에서는 repo 메서드들이 호출되었는지를 검증한다.

즉 1*repo.save(_,_)는 repo.save가 어떤 파라미터 두개를 가지고 한번 호출 되었음을 의미한다.

 

메서드 호출 회수 검증

위 예에서 살펴보았듯이 n*메서드의 형태는 해당 메서드가 n 번 호출 되어야 함을 검증한다.  여기서 주의할 사항은 이런 회수 검증은 검증에 성공하면 호출 회수는 소비된다는 점이다.

    def "회수 기반 호출 점검 시 주의사항_소비"() {
        when:
        service.save("hong", 30)

        then:
        1 * repo.save(!null, _)       // 이름이 not null인 상태로 save가 1번 호출된다.
        //1 * repo.save(!null, _)     // 이미 한번 소비해버린 호출 검증은 더 이상 동작하지 않는다.
        1 * repo.getScore("hong")     // hong을 이용해서 getScore가 1회 호출된다.

        when:
        service.printScore("jang")
        service.printScore("lim")

        then:"실제 메서드의 호출 순서는 무관하다."
        1 * repo.getScore({ String name -> name.length() == 3 })    // 파라미터 검증 가능
        1 * repo.getScore(_)                                        // 한번은 아무렇게나
        // 또는 아래처럼 2번 호출 되었는지 체크 가능
        //2 * repo.getScore(_)
    }

위의 예를 보면 첫 번째 when에서 service의 save를 문자열 hong과 30으로 호출한다. 즉 전체적으로 repo의 save는 한번만 호출된다. then 블록에서 repo의 save가 첫 번째 파라미터는 null이 아니고 두 번째는 아무거나 인 상태로 1회 호출되면 호출 회수는 다 사용했기 때문에 다음 라인에서 동일하게 호출하면 검증에 실패한다. 다음 라인은 service의 save에서 service의 printScore를 부르고 다시 여기서 repo의 getScore를 호출하기 때문에 1회 호출되는 것이 맞다.

두 번째 when 블록에서는 한번은 jang으로 한번은 lim으로 printScore를 호출한다. 이 메서드는 repo의 getScore를 호출하게 된다. then 블록에서는 두 개의 호출 검증이 있는데 이들의 호출 순서는 중요치 않다. 호출 되었는지 카운트만 중요하다. 첫 번째 검증 문에서는 문자열 파라미터가 3개인 녀석이 1회 호출되었음을 검증하고 두 번째 검증 문에서는 그냥 어떤 파라미터로 호출되기만 하면 된다. 또는 주석 처럼 단지 2번의 getScore 호출이 있었다는 식으로도 검증할 수 있다.

 

다양한 호출 회수 검증

호출 회수를 검증할 때 정확히 몇 번의 형태가 아니라 범위를 지정해줄 수 있다. 이때는 (n..m)*메서드 형태의 문장을 사용할 수 있고 검증에 통과하려면 n<=x<=m 만큼 호출되어야 한다. n, m은 각각 _로 대체 할 수 있다.

def "호출 회수를 다양하게 테스트해보자."() {  
    when:  
    service.printScore("hong")  
    service.printScore("jang")  
    service.printScore("lim")  
  
    then:  
    0* repo.scores()                // scores는 한번도 호출되지 않아야 한다.  
    (1..3) * repo.getScore(_)       // repo의 getScore는 1~3번 호출 되어야 한다. 
    (_..2) * repo.getScore(_)       // 동일한 조건은 재정의되지 않는다.  
}  

또 하나 주의할 점은 동일한 메서드 조건에 대해서는 검증 조건이 재정의 되지 않는다는 점이다 위 테스트의 then 블록을 살펴보면 repo의 getScore()를 호출을 검증할 때 먼저 (1..3)으로 되어있는데 다시 다음 라인에서 (_..2) 즉 2이하로 재설정 되어있다. 

이 상태에서 when 블록에서는 3번의 printScore가 호출되고 테스트는 통과한다. (_..2)라는 조건은 무시된 것이다. 즉 동일한 메서드에 대한 검증은 재정의되지 않는다.

 

검증의 동작 순서

앞서 밝혔듯이 하나의 then 블록은 검증에 순서가 없다. 즉 아래의 두 가지 테스트는 then에서의 검증 순서가 다르지만 모두 통과한다.

service의 save가 내부적으로 repo의 save와 getScore를 호출함을 상기하자.

    def "실행 순서가 생각대로 가는지 확인해보자."() {
        when:
        service.save("hong", 30)

        then:
        1 * repo.save(_, _)
        1 * repo.getScore(_)
    }
    def "실행 순서가 생각대로 가는지 확인해보자."() {
        when:
        service.save("hong", 30)

        then:
        1 * repo.getScore(_)
        1 * repo.save(_, _)
    }

만약 호출되는 메서드의 순서까지 테스팅 하기 위해서는 하나의 when 블록에 여러 개의 then 블록을 구성해서 사용할 수 있다. 이 경우 아래처럼 save -> getScore의 호출 순서를 지정하면 테스트를 통과한다.

    def "실행 순서가 생각대로 가는지 확인해보자."() {

        when:
        service.save("hong", 30)

        then:
        1 * repo.save(_, _)
        then:
        1 * repo.getScore(_)
    }

하지만 아래의 경우는 통과하지 못할 것이다.

    def "실행 순서가 생각대로 가는지 확인해보자."() {

        when:
        service.save("hong", 30)

        then:
        1 * repo.getScore(_)
        then:
        1 * repo.save(_, _)
    }

 

Mock을 이용한 Stubbing

Mock은 단순히 호출 여부만 확인할 수 있는 것이 아니라 Stub의 역할까지 수행할 수 있다. 아래의 테스트는 setup에서 repo의 scores를 호출하면 [1,2,3]을 반환하도록 되어있다.  그리고 when에서 service.sumScores는 호출하면 결과는 6이 될것이고 then 블록에서는 sum=6이고 repo.scores가 1회 호출되었음을 검증하니까 완벽해보인다.!!

    def "물론 매번 실행 회수만 확인해보는건 아니다."() {
        setup:
        repo.scores() >> Arrays.asList(1,2,3)

        when:
        Integer sum = service.sumScores()
        service.printScore("hong")

        then:
        sum==6
        1 * repo.getScore(_)
        1 * repo.scores()
    }

하지만 위 테스트는 전혀 예상치 못한 NullPointerException이 발생하면서 실패하게 된다.

java.lang.NullPointerException
	at com.quietjun.service.ScoreServiceImpl.sumScores(ScoreServiceImpl.java:15)
	at com.quietjun.T06_MockTest.물론 매번 실행 회수만 ~.(T06_MockTest.groovy:77)

여기서 한가지 더 이해해야 할 내용은 Spock의 동작 순서이다. 아래 실행 블록의 순서를 살펴보면 setup에서 repo의 scores()에서 [1,2,3]을 반환하도록 검증 내용이 선언되었다. 이후 바로 when으로 내려가는 것이 아니라 then 블록에서 메서드 호출에 대한 검증 정보를 먼저 읽어들이다. 주의할 점은 여기서의 메서드 호출 회수 검증으로 즉 1*로 repo.scores 검증 정보를 재정의해버린다는 점이다.

따라서 setup에서 설정했던 repo의 scores는 [1,2,3]을 반환하지 못하고 null을 반환하게 되고 이것을 사용하는 sumScore에서는 NullPointerException이 발생하게 된다.

이처럼 메서드 호출과 반환 값을 함께 검증하고 싶은 경우는 then 영역에서 설정해주어야 한다.

    def "물론 매번 실행 회수만 확인해보는건 아니다."() {
        setup:
        repo.scores() >> Arrays.asList(1,2,3)

        when:
        Integer sum = service.sumScores()
        service.printScore("hong")

        then:
        sum==6
        1 * repo.getScore(_)
        1 * repo.scores() >> Arrays.asList(1,2,3)
    }

 

Mock vs Stub

위의 예로 우리는 Mock이 충분히 Stub의 기능을 수행할 수 있음을 알 수 있다. 실제로 Mock은 Stubbing + Mocking이 가능하고 Stub은 단지 Stubbing만 가능하다.

그럼 굳이 Stub이 별도로 존재하는 이유는 뭘까?

문서를 살펴보면 Stub의 반환값은 ambitious(야심차다 말고 거창하다로 해석해보자. ㅎ) 하다고 표현되어있다. Stub은 객체형 데이터를 반환할 때 Empty or Dummy 객체를 반환하고 Mock은 단순히 null을 반환한다.

 

spockframework/spock

The Enterprise-ready testing and specification framework. - spockframework/spock

github.com

아래의 interface를 Mock과 Stub으로 만들어서 테스트 해보자.

    interface MyInterface {
        int method1();
        Integer method2() ;
        List<Integer> method3();
    }
    def "Mock과 Stub은 그럼 뭐가 다를까? "() {
        setup:
        MyInterface mock = Mock()
        MyInterface stub = Stub()

        when:
        int mInt = mock.method1()   // 0
        int sInt = stub.method1()   // 0

        Integer mIntObj = mock.method2() // null
        Integer sIntObj = stub.method2() // 0

        String mObj = mock.method3()    // null
        String sObj = stub.method3()    // []

        then:
        println "기본형 반환: " + mInt + " : " + sInt
        println "Wrapper형 반환: " + mIntObj + " : " + sIntObj
        println "객체 반환: " + mObj + " : " + sObj
    }

테스트 결과 출력된 내용을 살펴보면 둘의 차이가 명확히 보인다.

기본형 반환: 0 : 0
Wrapper형 반환: null : 0
객체 반환: null : []

기본형의 경우 mock과 stub 모두 타입별 기본 값을 반환한다. 하지만 wrapper 형의 경우 mock은 null, stub은 기본형의 기본값인 0, List의 경우 mock은 역시 null, stub은 []을 반환한다.

즉 생각 없이 mock만 사용하다 보면 상황에 따라 null에 의한 side effect가 발생할 수도 있다.

 

Spy

 

Spy는 또 무엇?

Spy는 기본적으로 잘 동작하는 기존의 객체를 감싸는 Wrapper이다. 따라서 Mock이나 Stub처럼 interface를 이용해서 만드는것은 의미가 없다.

즉 아래와 같이 List interface를 이용한 Spy는 동작할 수 없다.

    def "spy는 객체의 레퍼이므로 interface를 이용해서 만들지 않는다."(){
        setup:
        List spy = Spy(List);

        expect:
        spy.isEmpty()==true
    }
spy.isEmpty()==true
|   |
|   org.spockframework.mock.CannotInvokeRealMethodException: Cannot invoke real method 'isEmpty' on interface based mock object

 

Spy 를 생성할 때는 Spy() 생성자 안에 감싸려는 클래스를 넣어주면 된다.

    def "실제 존재하는 객체를 이용해서 Spy를 만든다."() {
        setup:
        List spy = Spy(ArrayList)

        expect:
        spy.isEmpty() == true
    }

 

Mock과 유사한 Spy의 동작

Spy는 Mock과 유사하게 클래스 메서드와의 상호 작용을 확인할 수 있다.

    def "클래스 메서드와의 상호 작용 확인 가능"(){
        setup:
        List spy = Spy(ArrayList)
        List mock = Mock()

        when:
        boolean result = spy.isEmpty()   // 실제 ArrayList의 메서드 동작
        mock.isEmpty()  // Mock 객체의 메서드 동작

        then:
        1* spy.isEmpty() >> true;
        1* mock.isEmpty()
        result == true
    }

 

그럼 Mock만 쓰면 되지 않을까?

Spy는 여러가지 이유로 Mock을 사용하지 못할 때도 사용 가능하다. 가장 대표적인 예로 String은 final 클래스이기 때문에 Mock을 생성하지 못한다.

    def "Spy를 꼭 써야 하나요?"(){
        setup:
        String spy = Spy(String)
        String mock = Mock()

        when:
        spy.length()
        mock.length()

        then:
        1* spy.length()
        1* mock.length()
    }
org.spockframework.mock.CannotCreateMockException: 
Cannot create mock for class java.lang.String because Java mocks cannot mock final classes. 
If the code under test is written in Groovy, use a Groovy mock.

	at org.spockframework.mock.runtime.JavaMockFactory.createInternal(JavaMockFactory.java:48)
	at org.spockframework.mock.runtime.JavaMockFactory.create(JavaMockFactory.java:38)
	at org.spockframework.mock.runtime.CompositeMockFactory.create(CompositeMockFactory.java:42)
	at org.spockframework.lang.SpecInternals.createMock(SpecInternals.java:46)
	at org.spockframework.lang.SpecInternals.createMockImpl(SpecInternals.java:294)
	at org.spockframework.lang.SpecInternals.createMockImpl(SpecInternals.java:284)
	at org.spockframework.lang.SpecInternals.SpyImpl(SpecInternals.java:164)
	at com.quietjun.T07_SpyTest.Spy를 꼭 써야 하나요?(T07_SpyTest.groovy:41)


Process finished with exit code -1

Spy를 통해서 기존 클래스의 메서드도 재정의할 수 있는데 이 경우에도 final 메서드는 재정의할 수 없다.

또한 Spy는 실제 존재하는 객체를 wrapping하기 때문에 재정의하지 않은 메서드들도 연동해서 사용할 수 있다. 

    def "Spy vs Mock"(){
        setup:
        List<Integer> spy = Spy(ArrayList)
        
        when:
        spy.add(1)
        
        then:
        spy.size()==1
        1* spy.add(_)
        spy.get(0)==1
    }

위의 예는 ArrayList를 바탕으로 Spy를 만들고 when에서 1을 추가 후 then에서 여러 가지 메서드들의 동작을 테스트 한다.  이중에는 Mock처럼 add 메서드가 호출된 회수도 검증해볼 수 있다.

하지만 위 테스트를 Mock 기반으로 한다면... 

    def "Spy vs Mock"(){
        setup:
        List<Integer> spy = Mock()
        
        when:
        spy.add(1)

        then:
        spy.size()==1
        1* spy.add(_)
        spy.get(0)==1
    }

당연히 테스트는 실패한다. Mock에는 아무런 메서드도 동작하지 않기 때문이다.

spy.size()==1
|   |     |
|   0     false
com.sun.proxy.$Proxy12@378542de (renderer threw NullPointerException)

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.