Spock 체험기
이번 글에서는 내가 애용하는 테스트 프레임워크 Spock에 대한 간단한 소개와 함께, 최근에 겪었던(그리고 아직까지도 이해하지 못한) 문제를 소개하고자 한다.
테스트를 편리하게 해주는 Spock
난 평소에 테스트 코드를 짤 때 Spock framework를 즐겨서 사용하는 편이다. 테스트를 작성할 때 보다 보편적인 선택은 JUnit + Mockito인데, 틈틈이 Spock을 사용해본 결과 내가 생각하는 Spock의 상대적인 장점은 다음과 같다.
- 코드가 간결하고 직관적이다.
- 메소드 이름에 문자열을 사용할 수 있어서, 테스트 케이스의 내용이 이해가 잘 된다. JUnit에서는 메소드명을 최대한 구체적으로 적어서 테스트 내용을 설명하지만 공백을 사용할 수 없다 보니 아무래도 가독성이 떨어진다. 그런데 Spock에서는 아래 예시코드에서 보듯이 메소드 이름을 "maximum of two numbers"으로 지을 수 있다.
- given, when, then, expect, where 등의 키워드를 통해 작성해야 하기 때문에, 테스트를 작성할 때부터 짜임새 있게 작성하게 되며, 내용을 한 눈에 알아보기 쉽다.
- Groovy라는 언어 자체가 Java보다 더 간결하다. (근데 이 점이 크게 중요하지는 않은 것 같다)
- JUnit에서는 힘들게 했어야 하는 일들이 훨씬 쉬워진다.
- 우선 mock이 굉장히 간편하다.
- 가령 JUnit + Mockito에서 when(mockedObject.someMethod).thenReturn(mockedReturn) 방식을 통해 객체를 mock하고, verify(mockedObject, times(n)).someMethod()를 통해 호출을 확인한다면, Spock에서는 n * mockedObject.someMethod() >> mockedReturn 처럼 간단하게 한줄로 표현이 가능하다.
- argument를 자세히 테스트하는 것도 쉽다. 가령 JUnit에서는 ArgumentCaptor라는 것을 따로 사용해서 길게 작성해야 하는 것을, Spock에서는 it을 이용하여 간단하게 작성할 수 있다. 만약 String을 parameter로 받는 class가 있을 때, "길이가 3 이상인 문자열이 argument로 올 것이다"라고 기대하는 경우에는 1 * mockedObject.receive({ it.size() > 3 }) 과 같이 작성할 수 있다.
- 이외에도 Spock에서 지원하는 기능이 많다.
- 테스트 내용은 똑같지만 테스트 데이터 입력값만 다른 경우에 굉~장히 편리하다
- 내가 생각하는 가장 큰 장점인데, 공식문서에서는 이를 Data Driven Testing이라고 소개하고 있다.
- JUnit에서도 parameterized test를 통해 비슷한 테스트를 할 수 있지만, 작성하는 과정이 귀찮고 가독성도 많이 떨어진다고 생각한다.
- Spock 공식문서에서 밝히는 Data Driven Testing의 예시는 다음과 같다.
class MathSpec extends Specification {
def "maximum of two numbers"(int a, int b, int c) {
expect:
Math.max(a, b) == c
where:
a | b | c
1 | 3 | 3
7 | 4 | 7
0 | 0 | 0
}
}
a와 b의 값이 달라짐에 따라 그 결과값이 c가 될 것이라는 테스트를 이렇게 간단하게 작성할 수 있다.위 코드는 테스트 케이스를 하나만 작성했지만 데이터 set이 3개이기 때문에 실질적으로는 3개의 테스트를 한 번에 작성한 셈이 된다. where 블록 덕분에 어떤 input이 들어왔을 때 어떤 output이 나오는지 직관적으로 이해할 수 있다.
이러한 장점 때문에 나는 데이터의 상태에 따라서 결과가 달라지는 코드가 있을 때 Spock의 이 기능을 애용한다. 이렇게 작성하면 테스트 코드 자체가 기능의 스펙을 담고 있기 때문에, 작성한 코드의 의도를 이해하기 쉬워진다.
하지만...워낙 직관적이고 편리해서 "이것도 될까?"하고 마구 Spock을 사용하다 보면 예기치 못한 에러에 놀라곤 한다. 그 중에서 where 블록의 작동 원리 때문에 빠졌던 함정을 소개하고자 한다.
where 블록의 함정
where 블록은 반드시 테스트의 마지막에 위치해야 한다. 하지만 위치와 실행 순서는 다르다. where 블록이 given 블록보다 먼저 실행된다. 이를 모르면 다음과 같은 실수를 할 수 있다. (다음은 내가 엉터리로 만든 예시이다)
// Language.java
public enum Language {
KOREAN,
ENGLISH,
JAPANESE,
CHINESE,
SPANISH;
}
// Country.java
public enum Country {
KOREA,
US,
JAPAN,
CHINA,
TAIWAN,
SPAIN,
MEXICO,
UK,
AUSTRALIA,
NEW_ZEALAND,
}
// Member.java
@Setter
@Getter
public class Member {
String name;
int age;
Country country;
}
// MemberService.java
public class MemberService {
public static Language getLanguage(Member member) {
switch (member.getCountry()) {
case KOREA:
return Language.KOREAN;
case JAPAN:
return Language.JAPANESE;
case CHINA:
case TAIWAN:
return Language.CHINESE;
case SPAIN:
case MEXICO:
return Language.SPANISH;
default:
return Language.ENGLISH;
}
}
}
// MemberServiceTest.groovy
class MemberServiceTest extends Specification {
def "getLanguage - broken test that throws MissingPropertyException"() {
given:
def korea = Country.KOREA;
def member = new Member(
name: "name",
age: 16,
country: country
)
expect:
result == MemberService.getLanguage(member)
where:
country << korea
result << Language.KOREAN
}
}
MemberServiceTest를 보면 별다른 문제가 없어 보이지만, 실행을 하면 다음과 같은 에러가 발생한다: "groovy.lang.MissingPropertyException: No such property: korea for class: spocktest.MemberServiceTest"
이런 exception이 발생하는 이유는 where 블록이 먼저 실행되는데, 이 시점에는 korea라는 변수(given 블록에서 정의됨)를 모르기 때문이다.
@Shared 또는 static 사용해서 해결하기
공식문서에 따르면 @Shared 애너테이션을 변수에 달거나 static 필드 선언을 통해 where interation(뿐만 아니라 다른 메소드로 작성된 테스트들 모두)가 공유하는 변수를 선언할 수 있다. 이 방법을 통해 다음과 같이 위의 exception을 해결할 수 있다.
class MemberServiceTest extends Specification {
//static korea = Country.KOREA;
@Shared korea = Country.KOREA;
def "getLanguage - succeeds with @Shared or static"() {
given:
def member = new Member(
name: "name",
age: 16,
country: country
)
expect:
result == MemberService.getLanguage(member)
where:
country << korea
result << Language.KOREAN
}
}
참고로 공식문서에서 @Shared에 대해서 설명해놓은 부분을 좀 더 살펴보겠다. 테스트에서 사용하는 변수들을 다음과 같이 필드로 선언해놓고 쓸 때가 많다.
def obj1 = new Member()
def obj2 = Mock(Member)
...
def "test feature 1"() {
...
}
def "test feature 2"() {
...
}
그런데 저 테스트 메소드들은 필드 변수들은 공유하는 것이 아니라, 각자 별도의 객체를 가지게 된다. 즉 필드 변수를 공유하지 않는 것이 spock의 기본 동작이다. 이는 테스트가 독립적인 환경에서 실행되는 것이 바람직하므로 내려진 결정일 것이다.
하지만 가끔 필드 변수 중에서 초기화하는 비용이 너무 큰 객체이거나, 테스트 간에 interaction이 필요한 경우(오히려 테스트끼리 서로 영향을 끼치는 것을 테스트해야 하는 상황)에는 필드 변수를 공유하면 좋을 것이다. 이때 바로 @Shared를 사용하면 좋다. (같은 기능이지만 내부 동작상 static은 상수에만 사용해야 한다고 한다.) @Shared를 사용하는 것은 문법적으로는 setupSpec()에서 초기화하는 것과 같다고 한다. JUnit에서는 @BeforeAll에 해당하는 동작이라고 보면 된다.
그리고 앞에서 말했듯이 where에서 변수를 사용한다면 반드시 @Shared나 static로 선언된 변수를 사용해야 한다. 만약 @Shared나 static 변수가 아닌 변수를 where block에서 사용하면 "Only @Shared and static fields may be accessed from here"라고 친절하게 알려준다. 이렇게 해야 하는 이유는 잘 모르겠지만, where의 내부 동작과 관련되어 있지 않을까 추측해본다.
근데 여기서 왜 MissingPropertyException이 발생하지?
그런데 아직까지 원인을 잘 모르겠는 문제가 있다. 다음의 코드를 보자.
class MemberServiceTest extends Specification {
def "getLanguage"() {
given:
def member = new Member() {
{
name = "some-name"
age = 16
country = countryType
}
}
expect:
result == MemberService.getLanguage(member);
where:
countryType | result
Country.KOREA | Language.KOREAN
Country.JAPAN | Language.JAPANESE
Country.UK | Language.ENGLISH
}
}
정말 아무런 문제가 없어 보인다. 하지만 위 코드를 실행하면 똑같은 에러가 발생한다: "groovy.lang.MissingPropertyException: No such property: countryType for class: spocktest.MemberServiceTest". 이 경우에는 member를 생성할 때 countryType을 찾지 못하는 문제이다.
분명히 이상하다. countryType은 where절에 선언이 되어 있고, where이 given보다 먼저 실행될텐데 왜 모르는 거지?
사실 위에서 member 객체를 생성하는 코드가 살짝 특이하다. Java에서는 다음과 같은 코드이다.
Member member = new Member() {
{
name = "some-name";
age = 16;
country = countryType;
}
};
위와 같은 방법으로 객체를 생성하고, (이후에) property 값을 초기화해주는 방식을 Java 언어에서 뭐라고 부르는지 잘 모르겠다. 아무튼 이런 방식으로 member 객체를 만들었더니 문제가 생겼다. 원인은 잘 모르겠다.
반면, 다음과 같은 방식들에서는 테스트가 성공한다. 아마 대부분은 아래와 같은 방식으로 객체를 생성할 것이기 떄문에 위와 같은 문제는 겪을 가능성이 낮을 것 같다.
// 1. setter
class MemberServiceTest extends Specification {
def "getLanguage"() {
given:
def member = new Member()
member.setName("some-name")
member.setAge(16)
member.setCountry(countryType)
expect:
result == MemberService.getLanguage(member);
where:
countryType | result
Country.KOREA | Language.KOREAN
Country.JAPAN | Language.JAPANESE
Country.UK | Language.ENGLISH
}
// 2. Groovy의 named constuctor 방식
class MemberServiceTest extends Specification {
def "getLanguage"() {
given:
def member = new Member(
name: "some-name",
age: 16,
country: countryType
)
expect:
result == MemberService.getLanguage(member);
where:
countryType | result
Country.KOREA | Language.KOREAN
Country.JAPAN | Language.JAPANESE
Country.UK | Language.ENGLISH
}
}
위의 2번의 named constructor은 생성자가 없을 때 요긴하게 사용할 수 있는 방식이다. (Groovy 공식 문서 참고)
만약에 Member 클래스에 생성자가 있다면, 다음도 잘 작동한다.
// 3. constructor
class MemberServiceTest extends Specification {
def "getLanguage"() {
given:
def member = new Member("some-name", 16, countryType);
expect:
result == MemberService.getLanguage(member);
where:
countryType | result
Country.KOREA | Language.KOREAN
Country.JAPAN | Language.JAPANESE
Country.UK | Language.ENGLISH
}
MissingPropertyException에 빠질 수 있는 또다른 경우
이 경우는 내가 겪은 건 아니지만, 다른 사람이 겪은 걸 봤는데 공식문서에도 이 케이스에 대해 설명이 되어 있었다. 다음과 같은 코드를 실행하면 message 때문에 MissingPropertyException이 발생한다.
when:
publisher.send("hello")
then:
def message = "hello"
1 * subscriber.receive(message)
then은 결과를 확인하는 블록이다. Spock은 코드가 실행(when)되기 전, interaction에 대한 정보를 전부 가지고 있어야 한다. 따라서 1 * subscriber.receive(message)가 then 블록에 작성되어 있지만, 이를 when 블록이 시작되기 바로 직전으로 옮겨 버린다. 즉 given 블록에 가는 셈이다. (given 키워드는 생략해도 된다)
그런데 코드를 옮길 때 def message = "hello"도 함께 데려가면 문제가 없겠지만, 이 코드는 데려가지 않았기 때문에 (옮겨진) 1 * subscriber.receiver(message)가 실행될 때 message를 몰라서 exception이 발생하는 것이다.
이런 문제를 해결하기 위해서는 message 선언을 when 블록 이전이나 where 블록으로 옮겨야 한다. 또는 다음과 같이 interaction 블록을 활용하여 한 몸으로 움직일 수 있도록 한다.
when:
publisher.send("hello")
then:
interaction {
def message = "hello"
1 * subscriber.receive(message)
}