[번역]Spock – Data Driven Testing

Spock의 공식문서 중 Data Driven Testing를 번역해 보았습니다.

보실 때 오역이 있음을 감안하시고, 번역하기에 애매한 부분은 원문 그대로 옮겼습니다.


흔히 다양한 입력 및 예상된 결과로 동일한 테스트 코드를 여러번 실행하는 것이 유용하다.
spock의 데이터 기반 테스팅 지원은 이것을 최고의 기능으로 만든다.

Introduction

Math.max 메서드의 동작을 지정한다고 가정하자:

class MathSpec extends Specification {
  def "maximum of two numbers"() {
    expect:
    // exercise math method for a few different inputs
    Math.max(1, 3) == 3
    Math.max(7, 4) == 7
    Math.max(0, 0) == 0
  }
}

이 접근법은 이와 같이 단순한 경우에는 좋지만, 몇가지 잠재적인 단점이 있다.

  • 코드와 데이터가 섞여 있어 쉽게 독립적으로 변경할 수 없다.
  • 데이터를 외부 소스에서 쉽게 자동생성하거나 가져올 수 없다.
  • 동일한 코드를 여러번 실행하려면, 중복되거나 별도의 메서드로 분리해야 한다.
  • 실패한 경우, 실패를 일으킨 입력값이 무엇인지 바로 알 수 없을 수도 있다.
  • 동일한 코드를 여러번 실행하는 것은 별도의 메서드를 실행하는 것과 동일한 격리로 인해
    이익을 얻지 못한다.

spock의 데이터 기반 테스팅 지원은 이러한 문제를 해결하려고 한다.
시작하려면, 코드를 data-driven feature method로 리팩토링하자.
첫번째, 하드코딩된 integer 값들을 대체할 세개의 메서드 파라미터(데이터 변수라고 불리는)를
소개한다.

class MathSpec extends Specification {
  def "maximum of two numbers"(int a, int b, int c) {
    expect:
    Math.max(a, b) == c
    ...
  }
}

우리는 이 테스트로직을 끝냈지만, 여전히 사용할 데이터 값을 제공할 필요가 있다.
이것은 항상 메서드의 끝에 위치한 where: 블록에서 수행된다.
가장 단순한(가장 일반적인) 경우에, where: 블록은 데이터 테이블이 있다.


Data Tables

데이터 테이블은 고정된 값 집합을 사용하여 기능 메서드를 실행하는 편리한 방법이다.

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
  }
}

테이블 헤더라 불리는 테이블의 첫번째 행은 데이터 변수를 선언한다. 테이블 행이라 불리는
다음 행은 해당하는 값이 있다. 각 행에 대해 기능 메서드는 한번씩 실행된다.
이것을 메서드의 반복이라 부른다. 반복이 실패하더라도, 나머지 반복은 실행될 것이다.
모든 실패는 보고될 것이다.

데이터 테이블은 적어도 두개의 열이 있어야 한다. 단일 열 테이블은 다음과 같이 작성할 수 있다.:

where:
a | _
1 | _
7 | _
0 | _

Isolated Execution of Iterations

반복은 별도 기능 메서드와 동일한 방법으로 서로 분리된다. 각 반복은 specification class의
자체 인스턴스를 가져오고, setup과 cleanup 메서드는 각각 반복 전후에 호출된다.


Sharing of Objects between Iterations

반복간에 객체를 공유하려면, @Shared 또는 static 필드에 보관해야 한다.

NOTE  | @Shared와 static 변수는 where: 블록내에서 접근할 수 있다.

이러한 객체는 다른 메서드와도 공유된다.  현재 동일한 메서드의 반복 사이에서 객체를 공유하는
좋은 방법은 없다.  이 문제를 고려한다면, 각 메서드를 별도 스펙에 넣는 것을 고려하라, 모든 스펙은
동일한 파일에 보관될 수 있다. 이것은 일부 상용구 코드를 사용하여 더 나은 격리를 얻을 수 있다.


Syntactic Variations

이전 코드는 몇가지 방법으로 조정할 수 있다.
첫째, where: 블록은 이미 모든 데이터 변수를 선언하므로, 메서드 파라미터는 생략할 수 있다.
둘째, 입력과 예상된 출력은 이중 파이프 기호(||)를 사용하여 시각적으로 구분할 수 있다.
이것을 사용하면 코드는 다음과 같이 된다.

class MathSpec extends Specification {
  def "maximum of two numbers"() {
    expect:
    Math.max(a, b) == c

    where:
    a | b || c
    1 | 3 || 3
    7 | 4 || 7
    0 | 0 || 0
  }
}

Reporting of Failures

 max 메서드의 구현에 결함이 있어서 반복 중 하나가 실패한다고 가정하자.

maximum of two numbers   FAILED

Condition not satisfied:

Math.max(a, b) == c
    |    |  |  |  |
    |    7  0  |  7
    42         false

분명한 질문은 어떤 반복이 실패했고 데이터 값은 무엇인가이다. 이 예에서는, 두번째 반복에서
실패했다는 것을 알기가 어렵다. 다른 경우에 이것은 더 어렵거나 불가능할 수도 있다.
어떤 경우엔, spock이 실패를 보고하는 것보다 반복이 실패하는 것을 크고 분명하게 만들면
좋을 것이다. 이것이 @Unrolling 어노테이션의 목적이다.


Method Unrolling

@Unroll로 어노테이션된 메서드는 반복이 독립적으로 보고된다.

@Unroll
def "maximum of two numbers"() {
...
@Unroll이 기본이 아닌 이유는 무엇인가?
@Unroll이 기본이 아닌 한가지 이유는 일부 실행환경(특히 IDEs)은 테스트 메서드의 수를 미리
알려주고 실제 수가 다를 경우 특정문제가 발생하길 기대하기 때문이다.

다른 이유는 @Unroll이 보고된 테스트 수를 크게 바꿀 수 있기 때문이고 이것은 항상 바람직하지 않다.
unrolling은 메서드 실행에 아무런 영향을 주지 않는다. 그것은 단지 보고의 대안일 뿐이다.
실행환경에 따라 출력은 다음과 같다.

maximum of two numbers[0]   PASSED
maximum of two numbers[1]   FAILED

Math.max(a, b) == c
    |    |  |  |  |
    |    7  0  |  7
    42         false

maximum of two numbers[2]   PASSED

이것은 두번째 반복(인덱스 1 포함)이 실패했음을 알려준다. 조금만 더 노력한다면, 더 잘할 수 있다.

@Unroll
def "maximum of #a and #b is #c"() {
...

메서드 이름은 선행 해시 기호(#)로 표시된 placeholders를 사용하여 데이터 변수 a, b 및 c를
나타낸다. 결과에서 placeholders는 구체적인 값으로 변경된다.

maximum of 3 and 5 is 5   PASSED
maximum of 7 and 0 is 7   FAILED

Math.max(a, b) == c
    |    |  |  |  |
    |    7  0  |  7
    42         false

maximum of 0 and 0 is 0   PASSED

이제 우리는 max 메서드 입력 7과 0에 대해 실패한 것을 한 눈에 알 수 있다.
자세한 내용은 More on Unrolled Method Names를 참조하라

@Unroll 어노테이션은 스펙에 배치할 수도 있다. 이는 스펙의 각 데이터 주도 기능 메서드에
배치하는 것과 동일한 효과를 발휘한다.


Data Pipes

데이터 테이블은 데이터 변수에 값을 제공하는 유일한 방법은 아니다. 사실, 데이터 테이블은
하나이상의 데이터 파이프에 대한 syntactic sugar이다.

...
where:
a << [1, 7, 0]
b << [3, 4, 0]
c << [3, 7, 0]

왼쪽 시프트 연산자(<<)로 표시된 데이터 파이프 연산자는 데이터 변수를 데이터 공급자에게 연결한다.
데이터 공급자는 반복마다 하나씩 변수에 대한 모든 값을 보유한다.
반복하는 방법을 알고 있는 Groovy의 어떠한 객체도 데이터 공급자로 사용될 수 있다.
이것은 Collection, String, Iterable 및 Iterable 계약을 구현한 객체를 포함한다.
데이터 공급자가 반드시 데이터일 필요는 없다(Collection의 경우에)
그들은 텍스트 파일, 데이터베이스, 스프레드시트같은 외부 소스에서 데이터를 가져오거나
임의로 데이터를 생성할 수 있다. 데이터 공급자는 필요할 때만(다음 반복전에) 다음 값으로 질문된다.


Multi-Variable Data Pipes

데이터 공급자가 반복당 여러 값(Groovy가 반복하는 방법을 알고 있는 객체)을 반환하면,
동시에 여러 데이터 변수에 연결할 수 있다. 문법은 Groovy multi-assignment와 비슷하지만
왼쪽에는 parentheses 대신 brackets를 사용한다.

@Shared sql = Sql.newInstance("jdbc:h2:mem:", "org.h2.Driver")

def "maximum of two numbers"() {
  expect:
  Math.max(a, b) == c

  where:
  [a, b, c] << sql.rows("select a, b, c from maxdata")
}

관심이 없는 데이터는 밑줄(_)로 무시할 수 있다.

...
where:
[a, b, _, c] << sql.rows("select * from maxdata")

Data Variable Assignment

데이터 변수에는 직접 값을 할당할 수 있다:

...
where:
a = 3
b = Math.random() * 100
c = a > b ? a : b

할당은 모든 반복마다 재평가된다. 위에 표시한 것처럼, 할당의 오른쪽은 다른 데이터 변수를
참조할 수 있다.

...
where:

where:
row << sql.rows("select * from maxdata")
// pick apart columns
a = row.a
b = row.b
c = row.c

Combining Data Tables, Data Pipes, and Variable Assignments

필요에 따라 데이터 테이블, 데이터 파이프 및 변수 할당을 결합할 수 있다.

...
where:
a | _
3 | _
7 | _
0 | _

b << [5, 0, 0]

c = a > b ? a : b

Number of Iterations

반복횟수는 사용가능한 데이터 양에 따라 다르다. 동일한 메서드를 연속적으로 실행하면
반복 횟수가 달라질 수 있다.데이터 공급자의 peer가 다른 값보다 빨리 실행되면,
예외가 발생할 것이다. 변수 할당은 반복횟수에 영향을 주지 않는다.
where: 블록은 할당이 하나만 있으면 정확히 하나의 반복을 만든다.


Closing of Data Providers

모든 반복이 완료된 후, 인자가 없는 close 메서드는 이 메서드가 있는 모든 데이터 제공자에서
호출된다.


More on Unrolled Method Names

 unroll 메서드 이름은 Groovy GString과 비슷하지만, 다음과 같은 차이가 있다.

  • 표현식은 $대신에 #으로 표시되고, ${…} 문법에는 해당되지 않는다.
  • 표현식은 속성 접근 및 인자가 없는 메서드 호출만 지원한다.

이름과 나이 속성을 가진 Person 클래스와 Person 타입의 person 데이터 변수가 주어지면
유효한 메서드 이름은 다음과 같다.

def "#person is #person.age years old"() { // property access
def "#person.name.toUpperCase()"() { // zero-arg method call

위의 #person과 같은 String 아닌 값은 Groovy semantics에 따라 String으로 변환된다.

다음은 잘못된 메서드 이름이다.

def "#person.name.split(' ')[1]" {  // cannot have method arguments
def "#person.age / 2" {  // cannot use operators

필요하다면, 복잡한 표현식을 유지하기 위해 추가 데이터 변수를 도입할 필요가 있다.

def "#lastName"() { // zero-arg method call
  ...
  where:
  person << [new Person(age: 14, name: 'Phil Cole')]
  lastName = person.name.split(' ')[1]
}
Advertisements