티스토리 뷰

Backend

Spock in Maven

지마켓 양승권 2022. 8. 26. 14:09

안녕하세요. 저는 지마켓에서 백엔드 시스템을 개발하고 있는 양승권이라고 합니다.

이번에 저희 시스템 중 Maven으로 개발되어있는 시스템들에 Spock framework를 적용한 경험을 공유하고자 합니다.


하고 싶었던 것


신규 프로젝트를 진행하게 되면서 새로운 기능을 개발할 일이 생겼는데, Maven 프로젝트 라서 그런지 Test 쪽이 좀 부족한 상황이었습니다.

그래서 이 참에 한번 이 프로젝트에 적용해보고 다른 Maven 프로젝트에도 적용해보자고 생각했습니다.

아래와 같은 내용을 해보고 싶은 내용들을 도출하였습니다.

  • Spock library를 활용하여 좀 더 직관적인 테스트를 해보자.
  • Spock의 Groovy로 Test code를 작성해보자.
  • 프로필 별로 구분하여 단위/통합 테스트를 구분하여 수행해보자.
  • 배포 시에 테스트 통과가 안되면 fail 하도록 강제해보자.

간단히 알아보기 : Spock란?


Spock은 Java 및 Groovy 애플리케이션을 위한 테스트 및 사양 프레임워크입니다.

Spock는 JUnit runtime을 사용하기 때문에 대부분의 IDE, 빌드 도구 및 지속적 통합 서버와 호환됩니다.

Spock를 사용하기 위해서는 아래와 같은 코드가 필요합니다.

import spock.lang.*

그리고 Specification을 상속받습니다.

class SampleSpec extends Specification{
    ....
}

그리고 실제 Spock코드를 작성하기 위해서는 block과 phasedp 대한 개념을 알아둘 필요가 있습니다.

Spock는 아래와 같은 code block과 Phases로 구성되어 있습니다.

phase는 Spock Test class의 life cycle이라고 볼 수 있는데요, 예를 들면 Setup은 JUnit의 @before역할을 하고 있습니다.

기본적인 Block에 대한 몇 가지 설명을 보시겠습니다.

 

given

given 블록은 테스트 코드가 필요한 설정 작업을 수행합니다.

 

when, then

when, then은 언제나 함께 발생하는데 when은 코드에 대한 동작을 의미하고 then은 assert 문이 필요 없이 결과에 대한 검증을 수행합니다.

 

expect

expect은 동작과 검증을 의미하지만 when, then과 expect의 차이를 공식 사이트에서는 아래와 같이 설명하고 잇습니다.

 

"As a guideline, use when-then to describe methods with side effects, and expect to describe purely functional methods."

 

where

공식사이트에서는 Data Driven Testing이 가능하도록 한 feature라고 소개를 하고 있습니다.

즉, 코드에 대한 동작을 검증할 때 where에 입력값과 결과 값을 작성할 경우 그에 대한 검증이 가능합니다.

 

아래에 간단한 Sample code를 보시겠습니다.

먼저 given, when, then으로 code를 작성해보겠습니다.

   def "validate spring concatenation"() {
        given:
        //act
        def string1 = "hello11"
        def string2 = "world!!"
        when:
        //arrage
        def stringConcatenated = string1.concat(string2)

        then:
        //assert
        stringConcatenated == "helloworld!!"
    }

when, then 결과는 아래와 같습니다.

 

when-then 테스트 수행결과

같은 내용을 expect로 작성해보겠습니다.

    def "validate spring concatenation"() {
        given:
        //act
        def string1 = "hellooo"
        def string2 = "world!!"

        expect:
        string1.concat(string2) == "helloworld!!"

    }

결과는 아래와 같습니다.

 

실패한 결과

이번에는 expect와 where를 사용해서 작성해보겠습니다.

    def "validate spring concatenation"() {
        given:
        //act
        def string1 = "hello"

        expect:
        string1.concat(string2) == result

        where:
        string2 | result
        "mike"  | "hellomike"
        "john"  | "hellojohn"
        "Tom"   | "hellottop"

    }

결과는 아래와 같습니다.

 

where 중 2개는 성공 1개는 실패


준비하기 : POM.xml 수정


자 이제는 실제 프로젝트 환경에서 구동을 살펴보기 위해 프로젝트의 세팅을 수행해보도록 합시다.

pom.xml을 수정해야 합니다.

 

gmavenplus-plugin

먼저 Groovy를 사용할 것이기에 groovy파일들을 class 화 시켜주기 위한 플러그인을 추가해줍니다

<plugin>
    <!-- The gmavenplus plugin is used to compile Groovy code. To learn more about this plugin,
    visit https://github.com/groovy/GMavenPlus/wiki -->
    <groupId>org.codehaus.gmavenplus</groupId>
    <artifactId>gmavenplus-plugin</artifactId>
    <version>1.12.0</version>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
                <goal>compileTests</goal>
            </goals>
        </execution>
    </executions>
</plugin>
gbuild-helper-maven-plugin

Integretion Test도 테스트 폴더로 포함하도록 플러그인도 추가해줍니다.

<plugin>
    <!-- adding second test source directory (just for integration tests) -->
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>build-helper-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>add-integration-test-source</id>
            <phase>generate-test-sources</phase>
            <goals>
                <goal>add-test-source</goal>
            </goals>
            <configuration>
                <sources>
                    <source>src/integration-test/groovy</source>
                </sources>
            </configuration>
        </execution>
    </executions>
</plugin>

 

spock-core, spock-spring

Spock 및 spring을 지원하기 위한 라이브러리를 추가합니다.

    <dependency>
        <groupId>org.codehaus.groovy</groupId>
        <artifactId>groovy</artifactId>
        <version>2.5.8</version>
    </dependency>
    <dependency>
        <groupId>org.spockframework</groupId>
        <artifactId>spock-spring</artifactId>
        <version>1.3-groovy-2.5</version>
        <scope>test</scope>
    </dependency>
Test 관련 파일을 제외 : 별도의 프로필에서 테스트 수행 예정

default profile에서 Test를 수행을 안 하도록 설정할 것이기에 plugin 추가 시 해당 test 파일을 제외합니다.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludes>
            <exclude>**/*Test</exclude>
            <exclude>**/*Spec</exclude>
            <exclude>**/*Tests</exclude>
        </excludes>
    </configuration>
</plugin>
프로필 별 빌드를 위한 설정( 단위/ 통합 테스트 )

제가 확인한 바로는 Maven에서 단위/통합 테스트를 논리적으로 구분하여 수행할 수 있는 별도의 플러그인은 확인하지 못하였습니다.

하지만, Gradle 같은 경우는 'org.unbroken-dome.test-sets' 플러그인을 사용하면 논리적으로 단위/통합 테스트를 구분하는 것이 가능합니다만, Maven은 (좀 비효율적으로 보이지만) surefire-plugin을 활용하여 파일 명을 가지고 단위테스트와 통합테스트를 구분하는 것이 가능합니다.

 

먼저, 크게 unit 테스트를 위한 프로필 unit-test와 integration-test를 위한 프로필을 구성합니다.

각 프로필은 include설정으로 각 phase를 위한 테스트 파일을 포함합니다.

참고로 surefire-plugin의 phase의 의미는 아래와 같습니다.

integration-test : verify 단계에서 코드를 수행함
test : test 단계에서 코드를 수행함

저는 Spec으로 끝나는 파일들은 단위 테스트로, IntegrationTest로 끝나는 파일들은 통합 테스트로 구분하여 실행하기로 하였습니다.

 <profiles>
        <profile>
            <id>unit-test</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.apache.maven.plugins</groupId>
                        <artifactId>maven-surefire-plugin</artifactId>
                        <version>3.0.0-M7</version>
                        <executions>
                            <execution>
                                <id>unit-test</id>
                                <phase>test</phase>
                                <goals>
                                    <goal>test</goal>
                                </goals>
                                <configuration>
                                    <excludes>
                                        <exclude>none</exclude>
                                    </excludes>
                                    <includes>
                                        <include>**/*Spec</include>
                                    </includes>
                                </configuration>
                            </execution>
                        </executions>
                    </plugin>
                </plugins>
            </build>
        </profile>
        <profile>
            <id>integration-test</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.apache.maven.plugins</groupId>
                        <artifactId>maven-surefire-plugin</artifactId>
                        <version>3.0.0-M7</version>
                        <executions>
                            <execution>
                                <id>integration-test</id>
                                <phase>integration-test</phase> <!--verify 단계에서 test를 수행함-->
                                <goals>
                                    <goal>test</goal>
                                </goals>
                                <configuration>
                                    <excludes>
                                        <exclude>none</exclude>
                                    </excludes>
                                    <includes>
                                        <include>**/*IntegrationTest</include>
                                    </includes>
                                </configuration>
                            </execution>
                        </executions>
                    </plugin>
                </plugins>
            </build>
        </profile>
    </profiles>

테스트 수행해보기 : 프로필 별


단위 테스트 수행
  • 프로필 선택 : unit 테스트 수행

  • 테스트 결과 : unit 테스트 수행 결과
     
    • 단위 테스트에 해당하는 SampleSpec만 수행되었다.
    • phase는 test

 

통합 테스트 수행
  • 프로필 선택 : 통합 테스트에 해당하는 파일만 수행

  • 테스트 결과 통합 테스트에 해당하는 SampleIntegrationTest만 수행되었다.

  •  phase가 integration-test로 verify시 실패, install 불가로 build 오류를 발생시키도록 한다.


Mock testing ( feat. Cardinality )


Mock testing는 다양한 테스트가 가능하지만 그중에 Cardinality를 활용한 테스트를 공유하고자 합니다.
Cardinality는 해당 함수가 몇 번 호출되었는지를 확인하는 형태로 테스트가 가능합니다.

아래 예시에서는 ExpressShopService를 테스트 수행하도록 하여 그 내부의 itemUtil.confirmBigSmileExpressShopItem() 함수가 호출이 되었는지 확인하는 형태로 테스트를 수행하였습니다.

 

먼저 테스트 class에 annotation을 붙여줍니다.

@SpringBootTest
@ActiveProfiles(
        value = "dev"
)

그리고 테스트할 target class 작성합니다.

//test target class
private ExpressShopService expressShopService;

그리고 관련 dependency를 작성합니다.

//dependency
private UrlConfig urlConfig;
private ItemUtil itemUtil;
private RestTemplateBuilder restTemplateBuilder;

private HashOperations<String, String, ExpressShopSection> hashOperations;

Spring DI 가 필요한 bean의 경우는 @Autowire로 코드를 작성합니다.

@Autowired
RedisOperations<String, ExpressShopSection> expressShopSectionRedisOperations;

그리고 Mock을 생성합니다. setup phase에서 실행하여 테스트 코드가 수행 전에 먼저 수행되도록 합니다.

public void setup(){
    //mocks
    urlConfig = Mock()
    itemUtil = Mock()
    restTemplateBuilder = Mock()

    //Mocks for redis
    expressShopSectionRedisOperations=Mock(RedisOperations.class)
    hashOperations = Mock()
    expressShopSectionRedisOperations.opsForHash() >> hashOperations

    expressShopService = new ExpressShopService(urlConfig, itemUtil, restTemplateBuilder,expressShopSectionRedisOperations)
}

그리고 Sample test code를 작성합니다.

def "Redis Sample Test"() {
    given:
    //in setup phase
    when:
    //act
    def result = expressShopService.getExpressShopSection()
    then:
    1 * hashOperations.get(_ as String, _ as String)
    result != null
    4 * itemUtil.confirmBigSmileExpressShopItem(_)

}

코드에 대한 좀 더 자세히 살펴보도록 합시다.

 

까다로운(?) RedisOperations :
HashOperations Mocking( DI + Constructor에서 추가적인 동작이 일어날 경우 )

ExpressShopService은 생성 시에 RedisTemplate bean을 injection 하여 사용하고 있습니다.
HashOperations을 그 멤버 변수로 두고 있는데, contructor에서 아래와 같은 code로 HashOperations이 결정하고 있습니다.

this.expressShopSectionHashOperations = expressShopSectionRedisOperations.opsForHash();

자, 이것을 어떻게 Mocking 할 것 인가? 그 방법은 위에서 작성한 코드의 아랫부분과 같습니다.

expressShopSectionRedisOperations=Mock(RedisOperations.class)
hashOperations = Mock()
expressShopSectionRedisOperations.opsForHash() >> hashOperations

먼저 알아두어야 할 것은 Interface의 경우 Mock을 할 경우 객체를 return 하는 method의 경우 기본적으로 null을 반환합니다.

위에서 정의한 '구현체가 있는 클래스'들의 Mocking과는 다릅니다.

그렇기에 test code에서 호출하는 아랫부분은 null exception이 되어 수행하지 못합니다.

    1 * hashOperations.get(_ as String, _ as String)

Null객체로 get을 호출하여 Mocking이 불가능하다

위처럼 Null객체로 get을 호출하여 Mocking이 불가능합니다.

그렇기 때문에 두 개의 interface를 각 Mock을 한 후 생성자에서 expressShopSectionRedisOperations.opsForHash()를 호출할 경우 hashOperations의 Mock 객체를 return 하도록 합니다.

실행 화면 : 먼저 RedisOperation이 Mock된다.

실행 화면 : 먼저 RedisOperation이 Mock 된다.

그 후 HashOperation이 Mocking되는 모습

Argument Constraints

confirmBigSmileExpressShopItem함수의 spec 원래 이렇습니다.

confirmBigSmileExpressShopItem(List<ExpressShopItem> items)

 

하지만 Spock에서는 argument Constraints를 활용할 수 있습니다.

즉, '_'입력만으로 코드 수행이 가능합니다.

1 * subscriber.receive(_)              // any single argument (including null)
1 * subscriber.receive(*_)             // any argument list (including the empty argument list)
1 * subscriber.receive(!null)          // any non-null argument
Cardinality

cardinarlity는 method의 호출 횟수를 나타냅니다. 공식 사이트에서는 아래와 같이 설명하고 있습니다.

1 * subscriber.receive("hello")
|   |          |       |
|   |          |       argument constraint
|   |          method constraint
|   target constraint
cardinality

이는 특정 조건에 따라 호출 횟수가 고정값일 수도 있고 아닐 수도 있음을 의미합니다.

1 * subscriber.receive("hello")      // exactly one call
0 * subscriber.receive("hello")      // zero calls
(1..3) * subscriber.receive("hello") // between one and three calls (inclusive)
(1.._) * subscriber.receive("hello") // at least one call
(_..3) * subscriber.receive("hello") // at most three calls
_ * subscriber.receive("hello")      // any number of calls, including zero

예시 코드에서는 3번 호출하여 fail이 나도록 작성하였습니다.

 

테스트 결과

3번을 호출하도록 하였으나 4번이 호출되어서 테스트가 fail이 되었습니다.


마무리


아직 좀 더 다음 어야 할 내용들이 있지만 짬짬이 실제 프로젝트에 적용해보고 수행해 보았습니다.

막상 코드 작성 시 refactoring이 충분히 이루어지지 않으면, 테스트가 수행이 참 어려움이 있는 것을 다시 느꼈습니다.

그래야 테스트의 coverage를 높일 수 있고 완성도 있는 코드를 수행할 수 있을 거라 생각이 듭니다.

모두 TDD에 성공하시는 그날까지, Techtok!


References


Spock Framework Reference Documentation
https://spockframework.org/spock/docs/2.1/index.html

Stubbing and Mocking in Java with the Spock Testing Framework
https://semaphoreci.com/community/tutorials/stubbing-and-mocking-in-java-with-the-spock-testing-framework

댓글