[Spring] Spock을 이용한 테스트 케이스 작성

스프링과 Spock을 이용한 테스트 / JUnit과 비교

Posted by Wonyong Jang on May 01, 2022 · 22 mins read

1. Spock 소개

Spock는 BDD(Behaviour Driven Development) 프레임워크이다.
TDD(Test Driven Development)프레임워크인 JUnit과 비슷한 점이 많으나, 기대하는 동작과 테스트의 의도를 더 명확하게 드러내주고 산만한 코드는 뒤로 숨겨주는 등의 큰 장점이 있다.

TDD는 테스트 자체에 집중하여 개발하는 방식인 반면, BDD는 비즈니스 요구사항에 집중하여 테스트 케이스를 작성하게 된다.

또한, Groovy 언어을 이용해서 작성하지만 Groovy에서는 자바를 편하게 가져다 사용하기 때문에 자바 코드의 테스팅에도 사용할 수 있다.

Groovy란 JVM위에서 동작하는 동적 타입 프로그래밍 언어로 Java문법과 매우 유사하다.

만약 기존에 junit을 사용해본 경험이 있다면 spock을 배우는 것 역시 쉽다.
junit의 주요 요소들은 모두 spock에 있기 때문이다.
둘의 구성요소를 비교해보면 아래와 같다.

스크린샷 2022-04-30 오후 10 14 24

class ExampleSpecification extends Specification {
    def setupSpec() {
        // run before the first feature method  
    }

    def setup() {
        // run before every feature method   
    }

    def cleanup() {
        // run after every feature method 
    }

    def cleanupSpec() {
        // run after the last feature method   
    }

}

이제 JUnit을 기반으로 테스트를 작성할 때 불편했던 점과 Spock으로 해결할 수 있는 부분을 살펴보자.


2. Spock 시작하기

먼저 Spock 플러그인을 설치해보자. 이 플러그인은 테스트 클래스에서 Spock를 위한 구문 강조와 오류 표시를 지원한다.

스크린샷 2022-04-30 오후 10 07 17

그 후 Spock를 사용하기 위해서는 아래 의존성을 추가해야 한다.

plugins {
    id 'groovy' // groovy 지원 
    id 'java'
}

spock를 사용하기 위해서는 spock-core를 추가하고, 런타임에 클래스 기반 mock 생성하기 위해서는 byte-buddy가 필요하다.
또한, 스프링과 같이 사용한다면 spock-spring도 추가해준다.

testImplementation('org.spockframework:spock-core:2.1-groovy-3.0')
testImplementation('org.spockframework:spock-spring:2.1-groovy-3.0')   

// 런타임에 클래스 기반 mock을 만들기 위해서 필요 
testImplementation('net.bytebuddy:byte-buddy:1.9.3')   

간단한 사용법은 아래와 같다.

  • 테스트 클래스는 Groovy 클래스로 생성하고 Specification 클래스를 상속 받는다.
  • feature(테스트 메서드)는 def를 이용해서 함수로 선언한다.
  • feature의 이름은 명명 규칙과 무관하게 작성하므로 한글로 의도를 명확하게 써줄 수 있다.
  • 테스트를 진행한다.

Spock에서는 given(또는 setup), when, then과 같은 코드 블록을 block 이라 부르며, 테스트 메서드는 Spock에서 feature 메서드라고 하며 feature 메서드에는 이와 같은 블록이 최소한 하나는 들어 있어야 한다.

Specification은 extends하면 Spock Test 클래스가 된다.

  • setup/ given : 테스트에 필요한 값들을 준비한다.
  • when : 테스트할 코드를 실행한다.
  • then : when과 함께 사용해야 하며, 예외 및 결과값을 검증한다.
  • expect : then과 같으며(when과 then이 합쳐진 형태), when을 필요로 하지 않기 때문에 간단한 테스트 또는 where와 같이 사용된다.
  • where : 테스트 로직은 동일하고, 여러 파라미터 값으로 결과값을 확인하고 싶을 때 사용한다.

Spock에서는 given, when, then 외에 추가로 3개가 더있어 총 6단계의 라이프사이클을 가지고 있다.

Spock를 사용함으로써, 기존 JUnit을 사용하면서 아래와 같이 직접 주석으로 구분해주었던 부분을 명확하게 구분할 수 있게 되었다.

// given

// when

// then

Spock에서는 given, when, then 외에 추가로 3개가 더있어 총 6단계의 라이프사이클을 가지고 있다.

Spock를 사용함으로써, 기존 JUnit을 사용하면서 아래와 같이 직접 주석으로 구분해주었던 부분을 명확하게 구분할 수 있게 되었다.

스크린샷 2022-08-15 오후 4 30 18

2-1) 첫 번째 테스트 클래스 작성하기

프로젝트의 src > test > groovy를 에 새로운 Groovy 클래스를 생성한다.

스크린샷 2022-04-30 오후 11 03 29

간단한 Spock 테스트 코드를 통해 살펴보자.

groovy 언어로 작성하기 때문에 테스트 메소드 이름을 문자열로 작성할 수 있게 되었다.

자바에서도 한글 메서드명이 가능하긴 했지만, 가장 앞에 특수문자 사용하기 등의 제약조건이 있는 반면, groovy는 이 모든 제약 조건에서 빠져나올 수 있다.

이제는 정말 명확하게 테스트 케이스의 의도를 표현할 수 있게 되었다.

import spock.lang.Specification

class MainTest extends Specification{

    def "Hello의 길이는 정말 5글자인가?"() {

        given:
        def input = "hello"

        when:
        def result = input.length()

        then:
        result == 5
    }
}

위에서 def는 동적 타입을 선언하는 예약어이다. 선언 후에는 어떤 타입의 객체든 주입할 수 있다.
또한, def로 생성자를 구현하여 선언하면 하나의 유닛 테스트가 된다.

“Hello의 길이는 정말 5글자인가?” 라는 테스트를 생성하였다.

스크린샷 2022-05-19 오전 12 15 27

given, when, then 등의 블록들 간의 변수들은 공유되어 사용할 수 있다.
즉, given: 에서 선언한 변수는 then: 에서도 사용 가능하다.

2-2) where 블록 이용하여 테스트하기

여기서 where 블록에 대해서 생소할수 있는데, 아래 예제를 살펴보자.

class MainTest extends Specification{

    def "computing the maximum of two numbers"() {

        expect:
        Math.max(a, b) == c

        where:
        a | b | c
        5 | 1 | 5
        3 | 9 | 9
    }
}

위 코드를 실행해보면 아주 재밌는 결과를 볼 수 있다.
Math.max(a,b) == c 테스트 코드의 a,b,c에 각각 5,1,5 와 3,9,9가 입력되어 expect: 메소드가 실행된다.

Spock의 where를 잘 사용하면 데이터가 다르고 로직이 동일한 테스트에 대해 발생하는 중복코드를 많이 제거 할 수 있다.

위 a, b, c 파라미터와 | 로 구분하여 생성한 한 것을 Data Table이라고 부르며, Data Table은 적어도 2개 이상 컬럼이 필요하다.

하나의 파라미터로 테스트 하고 싶다면 아래와 같이 가능하다.

where:
a | _
1 | _
5 | _
2 | _

또한, where 코드에서 파라미터와 결과값을 보기 좋게 구분이 가능하다.

대신   사용 가능하다. 단지 보기 좋게 구분하기 위함이다.

where:
a | b || c
1 | 2 || 2
2 | 5 || 5

만약 이를 JUnit 기반의 테스트코드로 작성했다면 어떻게 작성했을까?
a, b, c 각 검사 케이스가 많아질 수록 중복코드가 계속해서 발생했을 것이다.

또한, 테스트가 실패되는 경우 JUnit은 제일 처음 실패한 케이스만 알 수 있다면, Spock은 실패한 모든 테스트 케이스와 그 내용을 더 상세히 알려준다.

스크린샷 2022-05-01 오후 5 24 34

@Unroll을 추가하여 메소드 이름에 각 변수명들이 매칭되어 테스트 결과에 각각 값이 반영되어 출력해줄 수도 있다.

class MainTest extends Specification{

    @Unroll
    def "computing the maximum of two numbers [입력값1: #a, 입력값2: #b, 결과값: #c]"() {

        expect:
        Math.max(a, b) == c

        where:
        a | b | c
        5 | 1 | 5
        3 | 9 | 9
        2 | 2 | 2
    }
}

스크린샷 2022-05-01 오후 6 21 35

2-3) 예외 테스트

Spock을 이용하여 예외가 발생하는지를 테스트 해보자.
아래는 입력값을 0으로 나눴을때 발생하는 에러를 확인하고 정상적으로 예외처리를 하는지 확인한다.

public class DivideUtils {
    public static int divide(int input, int divide) {
        if(divide == 0) {
            throw new ArithmeticException("0으로 나눌 수 없다.");
        }
        return input/divide;
    }
}
class MainTest extends Specification{

    def "음수가 들어오면 예외가 발생하는지 확인해보자"() {

        given:
        int input = 5

        when:
        DivideUtils.divide(input, 0)

        then:
        def e = thrown(ArithmeticException.class)
        e.message == "0으로 나눌 수 없다."
    }
}

Spock에서 예외는 thrown() 메서드로 검증할 수 있다.
thrown() 메서드는 발생한 예외를 확인할 수 있을 뿐만 아니라 객체를 반환하기 때문에 예외에 따른 메시지도 검증을 할 수 있다.
테스트 코드를 작성한 흐름에 따라 예외를 확인할 수 있으니, 처음 코드를 본 사람도 더 쉽게 이해가 가능하다.

2-4) Mock 테스트

Spock의 강력한 기능 중 또 하나는 Mock이다.

Mock을 생성하기 위한 방법은 아래 2가지 방법으로 진행 가능하다.

def numberBuilder = Mock(NumberBuilder.class)   
NumberBuilder numberBuilder = Mock()

Spock에서 Mock 객체의 반환값은 >> 로 설정할 수 있다.
아래 예제를 보자.

class MainTest extends Specification{

    def "입력값을 받아서 divideNumber로 나눈다."() {

2-5) 다양한 호출 회수 검증

아래와 같이 사용하면 메소드의 호출 횟수를 확인해 볼 수 있고, buildNumber()가 한번도 호출되지 않아야 한다.

then:
0 * numberBuilder.buildNumber()

위의 0 값을 1로 변경하면 1번 호출 되었는지를 확인한다.

호출 회수를 검증할 때 정확히 몇번의 형태가 아니라 범위를 지정해줄 수 있다.

then:
// 최소 1번 이상 실행   
(0.._) * numberBuilder.buildNumber()

// 최대 2번까지 실행   
(_..2) * numberBuilder.buildNumber()

// 최소 1번에서 최대 2번까지 실행   
(1..2) * numberBuilder.buildNumber()

2-6) 올인원 느낌의 편의성

Junit을 사용했을 때 assertThat 구문과 matcher, 그리고 여러 matcher를 제공해주는 Hamcrest라는 라이브러리를 알고 있을 것이다.

하지만, 항상 불편했던 부분이 matcher를 사용할 때 static import가 IDE에서 제대로 지원되지 않거나 지원되더라도 너무 많은 힌트를 줘서 헤멜 때가 많았다.

import static org.hamcrest.CoreMatchers.*;  
import static org.hamcrest.MatcherAssert.assertThat;  

3. Spock 사용시 주의사항

spock를 정확하게 이해하지 못하고 사용하게 되면 문제가 발생하는 경우가 있는데, 아래 예제를 통해서 살펴보자.

3-1) mocking에서 사용되는 변수의 위치를 주의하자

아래 코드는 requestAddressSearch 메서드의 retry 를 검증하는 테스트이다.
api 호출을 mockWebServer로 mocking 했고, 모두 504에러를 발생시키도록 했다.
모든 호출이 실패하면 null 리턴을 확인하는 테스트이며, 문제가 없는 코드로 보일 수 있다.

def "requestAddressSearch retry fail "() {
    given:
    def uri = mockWebServer.url("/").uri()

    when:
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))

    def address = "서울 성북구 종암로 10길"
    def result = kakaoAddressSearchService.requestAddressSearch(address) // 요청이 모두 실패하면 null 리턴   

    then:
    2 * kakaoUriBuilderService.buildUriByAddressSearch(address) >> uri
    result == null
}

하지만 테스트를 실행하면 MissingPropertyException이 발생하게 된다.
그 이유는 spock는 구문을 분석할 때 then block에 존재하는 mocking 구문을 파악한 후 when block 앞으로 이동시키기 때문이다.

즉 아래와 같이 코드가 구성될 것이다.

def "requestAddressSearch retry fail "() {
    given:
    def uri = mockWebServer.url("/").uri()
    2 * kakaoUriBuilderService.buildUriByAddressSearch(address) >> uri

    when:
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))

    def address = "서울 성북구 종암로 10길"
    def result = kakaoAddressSearchService.requestAddressSearch(address)

    then:
    result == null
}

따라서 address라는 변수는 실제 런타임 시 mocking 구문에서 찾을 수 없게 되어 에러가 발생한다.

위를 해결할 수 있는 방법은 첫번째로 address 변수를 given block에 명시하면 에러는 해결된다.

def "requestAddressSearch retry fail "() {
    given:
    def uri = mockWebServer.url("/").uri()
    def address = "서울 성북구 종암로 10길"

    when:
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))

    def result = kakaoAddressSearchService.requestAddressSearch(address)

    then:
    2 * kakaoUriBuilderService.buildUriByAddressSearch(address) >> uri
    result == null
}

또 다른 해결책은 interaction block을 활용하는 것이다.

def "requestAddressSearch retry fail "() {
    given:
    def uri = mockWebServer.url("/").uri()

    when:
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))

    def result = kakaoAddressSearchService.requestAddressSearch("서울 성북구 종암로 10길")

    then:
    interaction { // 해당 block을 활용하면 내부 코드가 함께 이동된다.    
        def address = "서울 성북구 종암로 10길"
        2 * kakaoUriBuilderService.buildUriByAddressSearch(address) >> uri
    }
    result == null
}
Explicit Interaction Blocks

Internally, Spock must have full information about expected interactions before they take place. 
So how is it possible for interactions to be declared in a then: block? The answer is that under the hood, Spock moves interactions declared in a then: block to immediately before the preceding when: block. 
In most cases this works out just fine, but sometimes it can lead to problems:

더 자세한 내용은 공식문서를 참고하자.

3-2) stubbing과 mocking은 동시에 명시하자

흔히 mockito에 테스트 코드를 작성할 때처럼 given 절에 stubbing, then 절에 mocking 관련 코드를 넣게 되면 spock에선 테스트가 실패할 수 있다.

그 이유는 mocking 구문을 when block 앞으로 이동시킬 때 동일한 대상으로 지정된 stubbing 구문은 정상 적용되지 않기 때문이다.

def "requestAddressSearch retry fail "() {
    given:
    def uri = mockWebServer.url("/").uri()
    def address = "서울 성북구 종암로 10길"

    // stubbing 했지만 확인해보면 작동하지 않는다!
    kakaoUriBuilderService.buildUriByAddressSearch(address) >> uri     

    when:
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))

    def result = kakaoAddressSearchService.requestAddressSearch(address)

    then:
    2 * kakaoUriBuilderService.buildUriByAddressSearch(address)
    result == null
}

위의 경우 uri를 stubbing 해주는 부분이 정상 작동 하지 않는 것을 확인할 수 있다.

따라서 이를 stubbing, mocking을 동시에 명시하면 문제는 해결된다.

def "requestAddressSearch retry fail "() {
    given:
    def uri = mockWebServer.url("/").uri()
    def address = "서울 성북구 종암로 10길"

    when:
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))
    mockWebServer.enqueue(new MockResponse().setResponseCode(504))

    def result = kakaoAddressSearchService.requestAddressSearch(address)

    then:
    2 * kakaoUriBuilderService.buildUriByAddressSearch(address) >> uri    
    result == null
}
Combining Mocking and Stubbing

As explained in Where to Declare Interactions, 
the receive call will first get matched against the interaction in the then: block. 
Since that interaction doesn’t specify a response, 
the default value for the method’s return type (null in this case) will be returned. 
(This is just another facet of Spock’s lenient approach to mocking.). 
Hence, the interaction in the setup: block will never get a chance to match.

Mocking and stubbing of the same method call has to happen in the same interaction.

더 자세한 내용은 공식문서를 참고하자.


마치며

간략하게 기존 테스트 코딩의 불편함을 살펴봤으며, Spock으로 많은 불편이 해소되고 코드가 간결해지는 것을 보았다.

어쩌면 테스트 편의성을 확보하는 일이 당장 시급하지 않을 수도 있다.
새 기능 구현, 트러블슈팅 등에 밀리기 쉽다. 하지만 그렇게 중요한 일정들 사이에서 어렵사리 작성했던 테스트 코드를 한 달 뒤에 다시 봤을 때 이해하지 못하겠다면 또 테스트를 작성하고 싶은 생각이 들까?

그래서 되도록이면 나중에 봤을 때 혹은 다른 개발자가 봤을 때에도 그 테스트 코드의 의도와 내용을 쉽게 파악할 수 있게 테스트를 작성하는 것이 중요하다.
또 그렇게 하는게 어렵지도 않다면 마다할 이유가 없지 않을까?

반드시 테스트 코드를 Java로 작성해야 하고 JUnit과 Mockito로 코딩해야 하는 환경이 아니라면 충분히 유연한 언어인 Groovy와 올인원 성격의 Spock으로 쉬우면서도 오래 두어도 신선한 테스트를 작성해보길 권장한다.


Referrence

https://pompitzz.github.io/blog/Groovy/spock-summary.html#mocking%E1%84%8B%E1%85%A6%E1%84%89%E1%85%A5-%E1%84%89%E1%85%A1%E1%84%8B%E1%85%AD%E1%86%BC%E1%84%83%E1%85%AC%E1%84%82%E1%85%B3%E1%86%AB-%E1%84%87%E1%85%A7%E1%86%AB%E1%84%89%E1%85%AE%E1%84%8B%E1%85%B4-%E1%84%8B%E1%85%B1%E1%84%8E%E1%85%B5%E1%84%85%E1%85%B3%E1%86%AF-%E1%84%8C%E1%85%AE%E1%84%8B%E1%85%B4%E1%84%92%E1%85%A1%E1%84%8C%E1%85%A1
https://goodteacher.tistory.com/340
https://d2.naver.com/helloworld/568425
https://www.baeldung.com/spring-spock-testing
https://jojoldu.tistory.com/229
https://goodteacher.tistory.com/336
https://spockframework.org/
https://techblog.woowahan.com/2560/
https://jojoldu.tistory.com/228
https://spockframework.org/spock/docs/1.0/spock_primer.html