Go에서의 Table-driven test

1. 개요

함수에 많은 분기가 있을 때 여러 입력/출력 조합을 테스트하고 싶을 것입니다. 각 케이스를 별도의 테스트 함수로 작성하면 테스트 코드가 많이 반복되고 유지보수가 어려워집니다.

Table-driven test는 Go 커뮤니티에서 가장 흔한 테스트 작성 방식입니다. 테스트 케이스를 “테이블” 형태의 데이터로 표현한 후 하나의 루프만 사용하여 모두 실행합니다.

이 글을 읽은 후, 서브테스트가 있는 table-driven test를 작성하는 방법, 올바른 에러 확인, 테스트 병렬 실행, 복잡한 struct 비교를 할 수 있게 될 것입니다. 또한 이 기법을 언제 사용하면 안 되는지도 알게 될 것입니다.

2. 테스트할 함수

주문에 할인 코드를 적용한 후의 금액을 계산하는 함수를 사용합니다. 이 함수는 충분한 분기를 가지고 있어서 좋은 예입니다:

// pricing/discount.go
package pricing

import (
	"errors"
	"strings"
)

var (
	ErrNegativeTotal = errors.New("총액이 음수일 수 없습니다")
	ErrInvalidCode   = errors.New("유효하지 않은 할인 코드입니다")
)

// ApplyDiscount는 할인 코드를 적용한 후의 금액(원)을 반환합니다.
func ApplyDiscount(total int, code string) (int, error) {
	if total < 0 {
		return 0, ErrNegativeTotal
	}

	switch strings.ToUpper(strings.TrimSpace(code)) {
	case "":
		return total, nil
	case "SALE10":
		return total * 90 / 100, nil
	case "GIAM50K":
		if total < 50000 {
			return 0, nil
		}
		return total - 50000, nil
	default:
		return 0, ErrInvalidCode
	}
}

이 함수는 다섯 가지 동작을 확인해야 합니다. 총액이 음수인 경우, 할인 코드가 없는 경우, SALE10 코드, GIAM50K 코드(두 가지 분기), 존재하지 않는 코드입니다. 또한 코드는 TrimSpace와 ToUpper로 정규화됩니다.

3. Table-driven test의 기본 구조

3.1. 첫 번째 버전

Table-driven test는 세 부분으로 구성됩니다: 테이블 선언, 테이블 반복, 결과 비교.

// pricing/discount_test.go
package pricing

import "testing"

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "할인 코드 없음", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 소문자 및 공백", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k 정상", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k 금액 부족", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		got, err := ApplyDiscount(tc.total, tc.code)
		if err != nil {
			t.Fatalf("%s: 예상치 못한 에러: %v", tc.name, err)
		}
		if got != tc.want {
			t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
				tc.name, tc.total, tc.code, got, tc.want)
		}
	}
}

설명:

  • tests는 anonymous struct의 slice입니다. 각 요소는 테스트 케이스이며, 입력(total, code)과 예상 출력(want)을 포함합니다.
  • name 필드는 어느 케이스가 실패했는지 알려줍니다.
  • for 루프는 함수를 호출하고 결과를 비교합니다. F(input) = got, want X 형식의 에러 메시지는 Go에서 널리 알려진 관례입니다.

새로운 케이스를 추가하려면 테이블에 한 줄만 추가하면 됩니다. 이것이 이 기법의 가장 큰 장점입니다.

3.2. 첫 번째 버전의 문제점

위 코드는 실행되지만 두 가지 단점이 있습니다:

  1. t.Fatalf는 전체 테스트를 중단합니다. 한 케이스가 실패하면 그 다음 케이스들이 실행되지 않습니다.
  2. 디버그할 때 특정 케이스 하나만 실행할 수 없습니다.

Subtest가 이 두 문제를 해결합니다.

4. t.Run을 사용한 Subtest

t.Run은 각 케이스마다 하나의 subtest를 생성합니다. 각 subtest는 자체 *testing.T를 가지므로 t.Fatal은 현재 케이스만 중단합니다.

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "할인 코드 없음", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 소문자 및 공백", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k 정상", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k 금액 부족", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("예상치 못한 에러: %v", err)
			}
			if got != tc.want {
				t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
					tc.total, tc.code, got, tc.want)
			}
		})
	}
}

이제 에러 메시지에서 tc.name을 출력할 필요가 없습니다. Go가 이미 subtest 이름을 출력에 포함시키기 때문입니다. -v 옵션으로 실행하면:

go test ./pricing -v
=== RUN   TestApplyDiscount
=== RUN   TestApplyDiscount/할인_코드_없음
=== RUN   TestApplyDiscount/sale10
=== RUN   TestApplyDiscount/sale10_소문자_및_공백
=== RUN   TestApplyDiscount/giam50k_정상
=== RUN   TestApplyDiscount/giam50k_금액_부족
--- PASS: TestApplyDiscount (0.00s)
    --- PASS: TestApplyDiscount/할인_코드_없음 (0.00s)
    --- PASS: TestApplyDiscount/sale10 (0.00s)
    --- PASS: TestApplyDiscount/sale10_소문자_및_공백 (0.00s)
    --- PASS: TestApplyDiscount/giam50k_정상 (0.00s)
    --- PASS: TestApplyDiscount/giam50k_금액_부족 (0.00s)
PASS

공백이 _로 바뀐 것을 볼 수 있습니다. 특정 케이스만 실행하려면 -run에 정규표현식을 전달합니다:

go test ./pricing -run 'TestApplyDiscount/giam50k' -v

이 명령은 이름이 giam50k로 시작하는 두 가지 케이스만 실행합니다. 케이스가 실패하면 출력은 다음과 같습니다:

--- FAIL: TestApplyDiscount (0.00s)
    --- FAIL: TestApplyDiscount/sale10 (0.00s)
        discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000

어느 케이스가 실패했는지, 어느 줄에서, 어떤 입력으로 바로 알 수 있습니다.

주의: 케이스 이름은 짧고, 중복되지 않으며, 터미널에서 쉽게 입력할 수 있게 하세요. 두 케이스의 이름이 같으면 Go가 자동으로 #01 같은 접미사를 붙여서 터미널에서 특정 케이스를 실행하기 어려워집니다.

5. 테이블 테스트에서 에러 확인

에러를 반환하는 케이스도 같은 테이블에 포함되어야 합니다. wantErr 필드를 error 타입으로 추가하고, errors.Is로 비교합니다:

package pricing

import (
	"errors"
	"testing"
)

func TestApplyDiscount_Errors(t *testing.T) {
	tests := []struct {
		name    string
		total   int
		code    string
		want    int
		wantErr error
	}{
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "총액 음수", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
		{name: "존재하지 않는 코드", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)

			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
			}
			if tc.wantErr != nil {
				return // 에러를 확인했으므로 반환값을 확인할 필요가 없습니다
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

설명:

  • 성공한 케이스의 경우 wantErr은 zero value인 nil입니다. 이때 errors.Is(nil, nil)은 true를 반환합니다.
  • 함수가 예상과 달리 에러를 반환하면 errors.Is(err, nil)은 false를 반환하고 테스트가 실패합니다.
  • errors.Is는 fmt.Errorf("...: %w", err)로 감싼 에러도 정상 작동합니다. err == tc.wantErr 비교는 그렇지 않습니다.

많은 코드베이스에서는 간단함을 위해 wantErr bool을 사용합니다. 이 방식은 에러가 있는지 여부만 알려줄 뿐 무엇인 에러인지는 알려주지 않습니다. 패키지에 sentinel error가 정의되어 있다면 구체적인 에러와 비교하는 것을 우선적으로 사용해야 합니다.

6. t.Parallel을 사용한 병렬 실행

테스트 케이스가 느릴 때 (I/O 호출, 무거운 처리), 여러 subtest를 병렬로 실행할 수 있습니다:

func TestApplyDiscount_Parallel(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "할인 코드 없음", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("예상치 못한 에러: %v", err)
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

t.Parallel()은 테스트 러너에게 이 subtest가 다른 병렬 subtest들과 함께 실행될 수 있음을 알립니다. 부모 함수는 subtest들을 일시 중지하고, 루프를 다 돈 후 모든 subtest를 동시에 실행합니다.

Go 1.22부터 각 for 루프의 반복마다 새로운 tc 변수가 생성되므로 클로저가 항상 올바른 값을 캡처합니다. Go 1.21 이전에는 반드시 변수를 복사해야 합니다:

for _, tc := range tests {
	tc := tc // Go < 1.22에서 필요
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		// ...
	})
}

이 줄을 이전 버전에서 빠뜨리면 모든 subtest가 마지막 케이스를 사용합니다. 테스트는 여전히 “pass”하지만 실제로는 한 가지 경우만 확인됩니다. 이것은 탐지하기 매우 어려운 버그입니다. go.mod의 go 버전을 확인하세요.

주의: 케이스들이 변경 가능한 상태를 공유하지 않을 때만 t.Parallel()을 사용하세요. 예를 들어 전역 변수, 공유 임시 파일, 공유 데이터베이스 테이블 등입니다. ApplyDiscount와 같은 순수 함수의 경우 성능상 이점이 거의 없습니다. 관례적으로 추가하지 마세요.

7. go-cmp를 사용한 복잡한 struct 비교

함수가 중첩된 struct를 반환할 때 != 연산자는 slice나 map을 포함한 struct에 사용할 수 없습니다. reflect.DeepEqual은 true/false만 반환하고 어디가 다른지 알려주지 않습니다. go-cmp 라이브러리가 이 문제를 해결합니다. 모듈에 추가하세요:

go get github.com/google/go-cmp/cmp
// go.mod (발췌)
require github.com/google/go-cmp v0.6.0

청구서를 작성하는 함수가 있다고 가정하세요:

// pricing/invoice.go
package pricing

type Invoice struct {
	Customer string
	Items    []string
	Total    int
}

func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
	final, err := ApplyDiscount(total, code)
	if err != nil {
		return Invoice{}, err
	}
	return Invoice{Customer: customer, Items: items, Total: final}, nil
}

cmp.Diff를 사용한 테스트:

package pricing

import (
	"testing"

	"github.com/google/go-cmp/cmp"
)

func TestBuildInvoice(t *testing.T) {
	tests := []struct {
		name     string
		customer string
		items    []string
		total    int
		code     string
		want     Invoice
	}{
		{
			name:     "sale10 코드 적용",
			customer: "An",
			items:    []string{"셔츠", "모자"},
			total:    200000,
			code:     "SALE10",
			want:     Invoice{Customer: "An", Items: []string{"셔츠", "모자"}, Total: 180000},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
			if err != nil {
				t.Fatalf("예상치 못한 에러: %v", err)
			}
			if diff := cmp.Diff(tc.want, got); diff != "" {
				t.Errorf("BuildInvoice() 다름 (-want +got):\n%s", diff)
			}
		})
	}
}

cmp.Diff는 두 값이 같으면 빈 문자열을 반환합니다. 다르면 읽기 쉬운 diff를 받습니다:

BuildInvoice() 다름 (-want +got):
  pricing.Invoice{
  	Customer: "An",
  	Items:    {"셔츠", "모자"},
- 	Total:    180000,
+ 	Total:    200000,
  }

-는 예상 값이고 +는 실제 값입니다. 메시지에 (-want +got) 규칙을 명시해야 독자가 추측하지 않아도 됩니다.

8. 흔한 실수와 사용하면 안 될 때

테이블 테스트 작성 시 흔한 실수들:

  • 테이블이 너무 커서 많은 flag 필드를 가짐. setupMock bool, skipValidation bool, expectCall int 같은 필드가 있고 루프 본문에 많은 if가 있으면 테스트에 로직을 작성하는 것입니다. 그럴 땐 테스트를 별도의 함수로 분리하세요.
  • t.Run 밖에서 t.Fatal 사용. 한 케이스가 실패하면 뒤따르는 모든 케이스의 결과가 손실됩니다.
  • slice 대신 map을 사용하면서 그 결과를 모르는 경우. map[string]struct{...}은 케이스 이름이 중복될 수 없고 실행 순서가 무작위이므로 케이스 간 숨겨진 의존성을 드러내기 쉽습니다. 하지만 출력 순서가 고정되지 않습니다. 둘 다 허용되지만 팀 전체가 일관성있게 사용해야 합니다.
  • 입력을 빠뜨린 에러 메시지. t.Errorf("틀림")이라고만 쓰면 디버그할 때 테스트 코드를 열어봐야 합니다. 항상 입력, got, want를 출력하세요.
  • 경계 케이스를 빠뜨림. 테이블은 빠진 케이스를 쉽게 볼 수 있게 도와줍니다. 0, 음수, 빈 문자열, 경계값(예: total: 50000과 GIAM50K)을 다시 확인하세요.

다음의 경우 table-driven test를 사용하면 안 됩니다:

  • 각 케이스마다 매우 다른 setup이 필요한 경우. 예를 들어 mock이 다른 데이터를 반환하거나 호출 순서가 다릅니다. 이들을 테이블에 집어넣으면 테스트를 이해하기만 더 어려워집니다.
  • 케이스가 한 두 개뿐인 경우. 간단한 테스트 함수가 더 이해하기 쉽습니다.
  • 무작위 입력을 탐색해야 하는 경우. 그럴 땐 fuzz test (func FuzzXxx(f *testing.F))를 고려해보세요. Go 1.18부터 기본 제공됩니다. f.Add를 통해 테이블 테스트를 seed corpus로 사용할 수도 있습니다.

9. 결론

Table-driven test는 많은 테스트 케이스를 데이터 테이블로 모아 하나의 루프로 실행하게 해줍니다. t.Run과 함께 사용하면 각 케이스가 독립적인 subtest가 됩니다. -run을 사용해 특정 케이스만 실행할 수 있고, 한 케이스의 실패가 다른 케이스들을 멈추지 않습니다.

기억해야 할 점들:

  • 각 케이스는 name, 입력, 예상 출력을 가져야 합니다.
  • wantErr error 필드와 errors.Is로 에러를 확인하세요.
  • t.Parallel()은 정말 필요할 때만 사용하고 Go 1.22 이전의 루프 변수 의미를 주의하세요.
  • 복잡한 struct를 비교할 때 cmp.Diff를 사용하세요.
  • 테이블에 제어 로직이 생기면 테스트를 분리할 시간입니다.
golangtestingtable-driven testunit test