본문 바로가기
Golang

속성 기반 테스트 in Golang (Property Based Testing)

by Limm_jk 2023. 3. 26.

대체적으로 개발할 때, 우리는 예제를 기반으로 테스트 하는 것을 선호합니다. 예제 기반 테스트는 앞선 설명과 같이 테스트 케이스를 예제를 통해 생성하는 방식으로, 특정 입력에 대해 기대되는 출력 값을 예제로 제공하고, 테스트 대상 코드의 출력 값이 기대되는 값과 일치하는지 여부를 검증합니다. 이 방식은 테스트 케이스를 생성하기 쉽고, 이해하기 쉽기 때문에 많이 사용되어 왔습니다. 아래와 같이 add라는 함수를 만들고 사례를 기반으로 테스트를 진행해보겠습니다.

// 두 개의 정수를 받아서 두 수의 합을 반환하는 함수
func add(a, b int) int {
    return a + b
}

// test
func TestAdd(t *testing.T) {
	tests := []struct {
		a, b, expected int
	}{
		{5, 2, 7},
		{10, 10, 20},
		{-3, -5, -8},
	}

	for _, test := range tests {
		result := add(test.a, test.b)
		if result != test.expected {
			t.Errorf("add(%d, %d) = %d; expected %d", test.a, test.b, result, test.expected)
		}
	}
}

 

위와 같이 세 가지 예제로서 add의 연산을 잘한다고 가정을 할 수 있습니다. 하지만, 예제 기반 테스트는 정말 모든 경우를 테스트할 수 있을까요? 위의 예시에서 a와 b의 값으로 IntMax값이 들어가면 어떻게 될까요? return 값이 int의 범위를 넘으므로 오버플로우로 인한 예상과 다른 값이 나오게 될 수 있을 것입니다.

 

예제 기반 테스트에서는 일반적으로 각각의 값에 대한 테스트 케이스를 작성합니다. 그러나 이러한 방식으로는 해당 변수가 다른 값을 가질 수 있는 경우를 고려하지 못합니다. 이런 엣지 케이스를 방지하기 위하여 얼마나 많은 예제를 줘야 할까요? 10개? 100개? 그래도 모든 경우를 테스트할 수 있을까요? 이는 시스템에 대한 완전한 커버리지를 제공하지 않으며 예상치 못한 버그를 발생시킬 가능성이 있습니다.

 

또한, 예제 기반 테스트는 구현 세부 정보에 강하게 결합되어 있습니다. 예제 기반 테스트를 작성하려면 테스트 대상 코드의 구현 세부 정보에 대한 이해가 필요합니다. 이는 코드가 변경될 경우 테스트 케이스를 다시 작성해야 할 가능성이 있고, 리팩토링 내성이 약한 테스트가 됨을 의미합니다.

속성 기반 테스트

이러한 문제를 해결하기 위해 속성 기반 테스트가 제안되었습니다. 속성 기반 테스트는 시스템이 만족해야 하는 속성을 정의하고 이를 기반으로 테스트 케이스를 작성합니다. 이를 통해 시스템이 처리해야 하는 모든 가능한 입력을 고려할 수 있으며 구현 세부 정보에 강하게 결합되지 않습니다.

 

위의 예시처럼 두 int 자료형의 변수를 입력 받아서 더해주는 함수에 대하여 테스트한다고 가정해 보면, 이 함수가 가져야 하는 속성은 다음과 같다고 정의할 수 있습니다.

  1. 모든 정수 n에 대해, add(n, 0)의 결과는 n과 같아야 한다.
  2. 모든 정수 n에 대해, add(n, -n)의 결과는 0과 같아야 한다.
  3. 정수 n과 양수 m에 대해, add(n, m)의 결과는 n보다 커야 한다.
  4. 정수 n과 음수 m에 대해, add(n, m)의 결과는 n보다 작아야 한다.

더 다양한 속성이 있을 수 있지만, 테스트를 위하여 위 네 가지만 정의해 보도록 해보겠습니다.

 

위에서 언급한 속성들을 코드로 녹여내기 위하여는 몇 가지 요소가 필요합니다.

  1. 속성을 기반으로 생성된 테스트 케이스
  2. 랜덤한 값을 생성하기 위한 데이터 생성기
  3. 테스트 케이스 결과를 검증하기 위한 검증 함수

이런 요소를 만족하기 위해서는 random, assert 함수 등을 이용하여 해결을 할 수 있습니다. 하지만 보다 더 편하게 하기 위한 다양한 라이브러리들이 존재합니다. golang에는 standard library에 내장되어 있는 quick, 그리고 gopterrapid 등이 존재합니다.

저는 이 라이브러리들 중 개인적으로 가장 손에 맞는 gopter를 기반으로 코드 예시를 들어보겠습니다.

속성 기반 테스트 in golang

먼저 gopter를 go mod에 추가해줘야 합니다.

go get github.com/leanovate/gopter

 

그리고 가장 먼저 test를 위하여 properties를 정의해주어야 합니다. 속성 기반 테스트를 위한 속성을 저장하는 collection이라고 볼 수 있는데, NewProperties 시점에 parameter를 받아서 몇 회 수행할지, 랜덤 시드 값을 몇으로 할지 등을 지정할 수 있습니다. nil로 넣으면 default로 지정되니, 최초에는 nil 혹은 명시적으로 gopter.DefaultTestParameters()를 넣어서 사용하고 파라미터 튜닝이 필요한 시점에 조작하는 것을 권장합니다.

func Test_Add(t *testing.T) {
	properties := gopter.NewProperties(gopter.DefaultTestParameters())
}

 

그리고 다음으로 property를 지정해야 합니다. 위에서 지정한 properties의 메서드 Property를 이용하여 지정할 수 있습니다. parameter로 name과 함께 function을 받아서 수행하는 형식으로 이루어지며, Prop이라는 타입으로 지정되어 있습니다.

직접 function을 정의해서 사용할 수도 있으나, generate 된 값을 더욱 효율적으로 사용하기 위하여 ForAll을 사용할 수 있습니다. ForAll 함수는 생성되는 모든 값에 대하여 condition이 true가 나와야 하는 property를 만듭니다. 가장 일반적으로 사용할 수 있는 조건이라고 볼 수 있습니다.

 

‘정수 n과 양수 m에 대해, add(n, m)의 결과는 n보다 커야 한다.’라는 속성을 대표로 하나에 대하여만 만들어 보겠습니다.

func Test_Add(t *testing.T) {
   properties := gopter.NewProperties(gopter.DefaultTestParameters())

	// 정수 n과 양수 m에 대해, add(n, m)의 결과는 n보다 커야한다.
	properties.Property("Adding a positive number to a number increases it", prop.ForAll(
		func(n, m int) bool {
			return add(n, m) > n
		},
		gen.Int(),
		gen.IntRange(1, math.MaxInt),
	))
}

 

그리고 값을 생성하기 위하여 gen.Int()를 사용하였는데, 초기에 지정한 횟수만큼 Int 범위의 값을 생성해 주는 함수입니다. prop.ForAll과 함께 쓰여 초기에 지정한 횟수만큼의 Int 범위의 값을 생성하고, 이 모든 값이 condition에서 true를 반환받아야 하는 테스트를 구축할 수 있습니다.

 

마지막으로 추가한 properties를 테스트하기 위하여 Run을 실행해주어야 합니다. 이때 report를 어떻게 받을지 물어보는데, ConsoleReporter를 통하여 console로 받아볼 수 있고, Reporter 인터페이스를 구현하여 원하는 대로 받아볼 수 있습니다. 단, nil 방어로직이 없으므로 nil을 넘기지는 않도록 해야 합니다.

func Test_Add(t *testing.T) {
	properties := gopter.NewProperties(gopter.DefaultTestParameters())

	// 정수 n과 양수 m에 대해, add(n, m)의 결과는 n보다 커야한다.
	properties.Property("Adding a positive number to a number increases it", prop.ForAll(
		func(n, m int) bool {
			return add(n, m) > n
		},
		gen.Int(),
		gen.IntRange(1, math.MaxInt),
	))

	// 속성 기반 테스트 실행
	if !properties.Run(gopter.ConsoleReporter(false)) {
		t.Errorf("Failed!")
	}
}

 

그리고 Run 메서드는 testing context를 모르고 있으므로, properties의 검증에 실패해도 testing context에 영향을 주지 않습니다. 그래서 Run의 반환 값으로 나오는 bool 값을 받아서 핸들링을 해주어야 합니다.

이렇게 완성한 코드를 실행시키면 다음과 같이 실패 로그가 뜨게 됩니다.

! Adding a positive number to a number increases it: Falsified after 1
   passed tests.
ARG_0: 1
ARG_0_ORIGINAL (31 shrinks): 2127548798
ARG_1: 922337203685477580

int의 범위를 넘어간 값이 연산 결과로 나오면서 overflow 인한 기대와는 다른 값이 나오게 됩니다. 이는 ‘정수 n과 양수 m에 대해, add(n, m)의 결과는 n보다 커야 한다.'라는 속성을 어긴 것이 되므로 위와 같이 실패하게 됩니다.

 

 

위에서 정의한 네 가지 속성에 대하여 작성한 테스트 코드는 아래 접은 글에 넣어두겠습니다.

더보기
func Test_Add(t *testing.T) {
	properties := gopter.NewProperties(gopter.DefaultTestParameters())

	// 모든 정수 n에 대해, add(n, 0)의 결과는 n과 같아야 한다.
	properties.Property("Adding 0 to a number returns the number itself", prop.ForAll(
		func(n int) bool {
			return add(n, 0) == n
		},
		gen.Int(),
	))

	// 모든 정수 n에 대해, add(n, -n)의 결과는 0과 같아야 한다.
	properties.Property("Adding the negative of a number to itself returns 0", prop.ForAll(
		func(n int) bool {
			return add(n, -n) == 0
		},
		gen.Int(),
	))

	// 정수 n과 양수 m에 대해, add(n, m)의 결과는 n보다 커야한다.
	properties.Property("Adding a positive number to a number increases it", prop.ForAll(
		func(n, m int) bool {
			return add(n, m) > n
		},
		gen.Int(),
		gen.IntRange(1, math.MaxInt),
	))

	// 정수 n과 음수 m에 대해, add(n, m)의 결과는 n보다 작아야한다.
	properties.Property("Adding a negative number to a number decreases it", prop.ForAll(
		func(n, m int) bool {
			return add(n, m) < n
		},
		gen.Int(),
		gen.IntRange(math.MinInt, -1),
	))

	// 속성 기반 테스트 실행
	if !properties.Run(gopter.ConsoleReporter(false)) {
		t.Errorf("Failed!")
	}
}

마치며

지금까지 예제 기반 테스트에 비하여 속성 기반 테스트가 가지는 이점에 대하여 이야기해보았습니다.

‘앗! 그렇다면 모든 예제 기반 테스트를 속성 기반 테스트로 바꿔야겠는걸?’이라는 생각이 드셨나요? 아쉽지만 속성 기반 테스트 또한 완벽하게 모든 경우를 테스트 할 수는 없습니다.

 

속성 기반 테스트는 위에서 설명한 것과 같이 랜덤한 값을 넣어서 테스트하기 때문에, 경곗값을 빠뜨리게 될 수 있습니다. 물론 랜덤하게 테스트하는 값이 많아질수록 이런 값을 잡을 확률은 높아지겠지만, 경곗값은 소프트웨어 테스팅에서 굉장히 중요한 부분이며, 이를 확률에 위임하기에는 위험성이 큽니다.

 

그러므로 예제 기반 테스트와 속성 기반 테스트를 함께 사용하여 상호 보완적인 효율적인 테스트 코드를 작성하는 것이 중요합니다.

댓글