테이블 주도 테스트
이 챕터에서 다루는 것
같은 함수를 입력만 바꿔 여러 번 검증하는 것이 테스트의 대부분이다. Go에서 그것을 쓰는 표준 형태가 **테이블 주도 테스트(table-driven test)**다. 케이스를 데이터로 적고 루프가 실행한다.
이 챕터는 그 형태와 t.Run, t.Parallel, t.Cleanup을 다룬다.
문제 — 복사·붙여넣기 테스트
8-1의 방식대로 케이스를 늘리면 이렇게 된다.
func TestMakeLower(t *testing.T) {
if got := Make("golang"); got != "golang" {
t.Errorf("Make(\"golang\") = %q, want %q", got, "golang")
}
}
func TestMakeUpper(t *testing.T) {
if got := Make("GoLang"); got != "golang" {
t.Errorf("Make(\"GoLang\") = %q, want %q", got, "golang")
}
}
// ... 열 개 더
문제가 셋이다.
- 케이스를 추가하는 비용이 크다. 함수 하나를 통째로 복사해야 한다.
- 검증 로직이 열 군데로 흩어진다. 형식을 바꾸려면 열 군데를 고친다.
- 빠진 케이스가 안 보인다. 무엇을 검증하고 있는지 한눈에 읽히지 않는다.
테이블 주도 테스트는 검증 로직을 하나로 두고 케이스만 데이터로 늘린다. 그래서 케이스 추가가 한 줄이 되고, 표를 훑으면 커버 범위가 바로 보인다.
대상 코드
// Package slug은 임의의 제목 문자열을 URL에 넣을 수 있는 형태로 바꾼다.
package slug
import (
"strings"
"unicode"
)
// Make는 s를 슬러그로 바꾼다.
//
// 규칙은 넷이다.
// - ASCII 대문자는 소문자로 바꾼다.
// - ASCII 영문자와 숫자는 그대로 둔다.
// - 그 밖의 문자는 하이픈 하나로 바꾼다.
// - 하이픈이 연속되면 하나로 접고, 앞뒤 하이픈은 버린다.
func Make(s string) string {
var b strings.Builder
b.Grow(len(s))
prevHyphen := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevHyphen = false
case unicode.IsUpper(r) && r < unicode.MaxASCII:
b.WriteRune(unicode.ToLower(r))
prevHyphen = false
default:
if !prevHyphen {
b.WriteByte('-')
prevHyphen = true
}
}
}
return strings.Trim(b.String(), "-")
}
기본 형태
func TestMake(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"소문자만", "golang", "golang"},
{"대문자 변환", "GoLang", "golang"},
{"공백은 하이픈", "hello world", "hello-world"},
{"연속 구분자는 하나로", "hello world", "hello-world"},
{"앞뒤 구분자는 제거", " hello ", "hello"},
{"숫자 유지", "go 1.26 release", "go-1-26-release"},
{"기호만", "!!!", ""},
{"빈 문자열", "", ""},
{"비ASCII는 구분자 취급", "한글 title", "title"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Make(tt.in)
if got != tt.want {
t.Errorf("Make(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
구성 요소를 하나씩 본다.
익명 구조체 슬라이스. 타입에 이름을 붙이지 않는다. 이 테스트 함수 밖에서는
쓰이지 않으니 이름이 낭비다. 필드 순서대로 값만 쓰는 리터럴({"소문자만", ...})이
관례다 — 케이스가 표처럼 정렬되어 읽힌다.
name 필드가 첫 번째. 실패했을 때 어느 케이스인지 알아야 하고, -run으로
하나만 고를 때도 쓴다.
tt라는 변수 이름. 표준 라이브러리 전체가 tt(table test)를 쓴다. tc도
흔하다. 굳이 다른 이름을 만들지 않는다.
t.Run으로 서브테스트를 만든다. 다음 절에서 자세히 본다.
:::note 루프 변수 캡처는 이제 걱정거리가 아니다
Go 1.21 이하에서는 tt := tt 한 줄을 루프 맨 위에 넣는 것이 필수였다. 루프
변수가 반복마다 재사용되어, 클로저가 마지막 값만 보게 됐기 때문이다.
Go 1.22부터 루프 변수는 반복마다 새로 만들어진다. 오래된 코드에서 tt := tt를
보면 그 시절의 흔적이다. 지금 새로 쓸 필요는 없다.
:::
t.Run — 서브테스트
t.Run(name, f)은 독립된 자식 테스트를 만든다. 넷을 얻는다.
- 케이스 하나가 실패해도 나머지가 돈다. 서브테스트 안의
t.Fatal은 그 서브테스트만 끝낸다. - 실패 리포트에 케이스 이름이 붙는다.
-run으로 하나만 고를 수 있다.t.Parallel을 케이스 단위로 걸 수 있다.
실패했을 때의 모습이다. 일부러 두 케이스의 기대값을 틀리게 바꾸고 돌렸다.
--- FAIL: TestMake (0.00s)
--- FAIL: TestMake/숫자_유지 (0.00s)
slug_test.go:29: Make("go 1.26 release") = "go-1-26-release", want "go-126-release"
--- FAIL: TestMake/기호만 (0.00s)
slug_test.go:29: Make("!!!") = "", want "x"
FAIL
케이스 이름의 공백이 언더스코어로 바뀐다. t.Run이 이름을 정규화하기
때문이다(공백 → _, 슬래시는 계층 구분자로 유지, 출력 불가 문자는 이스케이프).
이름이 겹치면 #01, #02가 붙는다.
-run으로 하나만 고를 때는 정규화된 이름을 쓴다.
go test -v -run '^TestMake$/^숫자_유지$' ./02-table-driven/slug
:::tip 실패 메시지에 무엇을 담을 것인가 입력, 실제 결과, 기대 결과 셋이 전부 있어야 한다. 그래야 로그만 보고 원인을 짐작할 수 있다.
- 나쁜 예:
t.Error("슬러그가 틀렸다")— 다시 돌려 봐야 한다. - 나쁜 예:
t.Errorf("got %q", got)— 무엇을 기대했는지 모른다. - 좋은 예:
t.Errorf("Make(%q) = %q, want %q", tt.in, got, tt.want)
케이스 이름이 입력을 이미 설명한다고 해서 입력을 빼지 않는다. 이름은 사람이
붙인 요약이고, %q로 찍은 입력은 사실이다.
:::
t.Parallel
서브테스트 안에서 t.Parallel()을 부르면 그 서브테스트는 일시 중지되고,
부모 테스트 함수가 반환한 뒤 다른 병렬 서브테스트들과 함께 재개된다.
func TestMakeProperties(t *testing.T) {
inputs := []string{"golang", "GoLang", "hello world", " hello ", "!!!", "", "한글 title"}
for _, in := range inputs {
t.Run(in, func(t *testing.T) {
t.Parallel() // 서브테스트끼리 병렬로 돈다
got := Make(in)
if strings.HasPrefix(got, "-") || strings.HasSuffix(got, "-") {
t.Errorf("Make(%q) = %q: 앞뒤에 하이픈이 남았다", in, got)
}
if strings.Contains(got, "--") {
t.Errorf("Make(%q) = %q: 하이픈이 연속된다", in, got)
}
if got != strings.ToLower(got) {
t.Errorf("Make(%q) = %q: 대문자가 남았다", in, got)
}
// 멱등성: 슬러그를 다시 슬러그로 만들어도 그대로여야 한다.
if again := Make(got); again != got {
t.Errorf("Make(Make(%q)) = %q, want %q", in, again, got)
}
})
}
}
이 테스트는 기대값 표가 없다. 대신 입력과 무관하게 항상 참이어야 하는 성질을 검증한다. 값이 아니라 성질을 적는 방식은 8-6의 퍼징으로 그대로 이어진다.
병렬 실행의 동시 실행 수 상한은 -parallel N이고 기본값은 GOMAXPROCS다.
:::warning t.Parallel을 쓸 자리와 쓰지 말 자리
쓸 자리: I/O를 기다리는 테스트(HTTP, DB, 파일). 대기 시간이 겹쳐 전체가 빨라진다.
쓰지 말 자리:
- 순수 계산 테스트.
Make같은 함수는 병렬화 오버헤드가 실행 시간보다 크다. 위 예제는 형태를 보여 주려고 붙인 것이지 성능 때문이 아니다. - 공유 상태를 건드리는 테스트. 병렬 서브테스트끼리 같은 변수를 쓰면
데이터 경합이다.
-race로 확인한다. :::
t.Setenv와 t.Parallel은 함께 쓸 수 없다
t.Setenv는 환경 변수를 바꾸고 테스트가 끝나면 되돌려 준다. 그런데 환경 변수는
프로세스 전역이라 병렬 테스트와 근본적으로 충돌한다. testing이 이것을
런타임 패닉으로 막는다.
PANIC: testing: test using t.Setenv, t.Chdir, or cryptotest.SetGlobalRandom can not use t.Parallel
같은 이유로 t.Chdir도 마찬가지다. 프로세스 전역 상태를 건드리는 테스트는
병렬화하지 않는다. 더 나은 답은 애초에 전역 상태에 의존하지 않게 만드는 것이고,
그 방법이 8-3의 의존성 주입이다.
t.Cleanup
t.Cleanup(f)은 그 테스트(또는 서브테스트)가 끝날 때 f를 부른다.
defer와 비슷하지만 두 가지가 다르다.
- 도우미 함수 안에서 등록할 수 있다.
defer는 그 함수가 반환할 때 도니까 픽스처를 만드는 도우미에서는 쓸 수 없다. - 서브테스트의 정리는 그 서브테스트가 끝날 때 돈다.
실행 순서는 등록의 역순, 즉 LIFO다.
// t.Cleanup은 등록의 역순(LIFO)으로 실행된다.
// 서브테스트의 Cleanup은 그 서브테스트가 끝날 때 돈다.
func TestCleanupOrder(t *testing.T) {
t.Cleanup(func() { t.Log("정리 1 (먼저 등록, 나중 실행)") })
t.Cleanup(func() { t.Log("정리 2 (나중 등록, 먼저 실행)") })
t.Run("서브", func(t *testing.T) {
t.Cleanup(func() { t.Log("서브테스트 정리") })
t.Log("서브테스트 본문")
})
t.Log("부모 본문 끝")
}
go test -v -run TestCleanupOrder ./02-table-driven/slug
=== RUN TestCleanupOrder
=== RUN TestCleanupOrder/서브
slug_test.go:91: 서브테스트 본문
slug_test.go:90: 서브테스트 정리
=== NAME TestCleanupOrder
slug_test.go:94: 부모 본문 끝
slug_test.go:87: 정리 2 (나중 등록, 먼저 실행)
slug_test.go:86: 정리 1 (먼저 등록, 나중 실행)
--- PASS: TestCleanupOrder (0.00s)
--- PASS: TestCleanupOrder/서브 (0.00s)
서브테스트의 정리가 부모 본문보다 먼저 끝난 것에 주목한다. 이것이 픽스처 도우미의 기반이다.
// 이런 도우미를 쓸 수 있게 해 주는 것이 t.Cleanup이다.
func newTestServer(t *testing.T) *Server {
t.Helper()
s := startServer()
t.Cleanup(s.Close) // 호출한 테스트가 끝날 때 자동으로 닫힌다
return s
}
8-3에서 httptest.Server를 이 형태로 정리한다.
맵을 케이스 컨테이너로 쓰기
케이스에 순서 의미가 없다면 맵도 쓸 수 있다.
// 맵을 케이스 컨테이너로 쓰면 순회 순서가 무작위가 된다.
// t.Run이 케이스를 독립시켜 주므로 순서 의존이 없다면 문제되지 않고,
// 오히려 순서에 기댄 테스트를 드러내 준다.
func TestMakeFromMap(t *testing.T) {
cases := map[string]string{
"Hello, World!": "hello-world",
"Go 1.26": "go-1-26",
"---dashes---": "dashes",
"MixedCASE Input": "mixedcase-input",
"trailing space ": "trailing-space",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
if got := Make(in); got != want {
t.Errorf("Make(%q) = %q, want %q", in, got, want)
}
})
}
}
Go의 맵 순회 순서는 무작위다. 여기서는 그것이 기능이다 — 케이스 사이에 숨은 순서 의존이 있으면 언젠가 드러난다. 단점은 실패 순서가 매번 달라져 로그를 비교하기 어렵다는 것이고, 그래서 기본은 슬라이스다.
어설션 라이브러리를 쓸 것인가
testify의 assert.Equal(t, want, got)은 확실히 짧다. 그럼에도 Go 표준
라이브러리와 대부분의 Go 프로젝트가 쓰지 않는 이유가 있다.
- 인자 순서를 외워야 한다.
assert.Equal(t, want, got)인가(t, got, want)인가? 틀리면 실패 메시지의 want와 got이 뒤바뀐다. assert와require가 다르다. 전자는Error, 후자는Fatal이다. 섞여 있으면 어느 것이 흐름을 끊는지 코드를 읽어야 안다.- 비교 규칙이 감춰진다.
assert.Equal은 리플렉션 기반이라 타입이 달라도 값이 같으면 통과할지, 포인터를 어떻게 비교할지가 라이브러리 문서에 있다.got != want는 언어 명세에 있다.
기본은 표준 라이브러리다. 구조체나 슬라이스 비교가 필요하면 slices.Equal,
maps.Equal을 쓰고, 깊은 비교와 좋은 diff가 필요하면 google/go-cmp의
cmp.Diff를 도입한다 — 이쪽이 testify보다 훨씬 널리 받아들여진 선택이다.
// go-cmp를 쓴다면 이런 형태다. diff가 실패 메시지에 그대로 들어간다.
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Make(%q) 불일치 (-want +got):\n%s", tt.in, diff)
}
흔히 하는 실수
1. 서브테스트 없이 루프만 돈다
for _, tt := range tests {
if got := Make(tt.in); got != tt.want { // t.Run이 없다
t.Errorf(...)
}
}
돌긴 돈다. 하지만 -run으로 케이스 하나를 고를 수 없고, 실패 리포트에 케이스
이름이 붙지 않으며, t.Fatal을 쓰면 나머지 케이스가 통째로 날아간다.
2. 케이스마다 검증 로직이 다르다
tests := []struct {
name string
in string
want string
wantErr bool
skip bool
custom func(t *testing.T, got string) // 여기서부터 잘못됐다
}{...}
표에 if가 늘어나기 시작하면 그것은 이미 테이블 주도 테스트가 아니다.
테스트 함수를 둘로 쪼갠다. 성공 케이스 표 하나, 에러 케이스 표 하나가
보통 정답이다.
3. 케이스 이름이 겹치거나 비어 있다
t.Run("", ...)을 여러 번 부르면 #01, #02가 붙어 어느 케이스인지 알 수 없다.
t.Run(tt.in, ...)처럼 입력을 이름으로 쓸 때는 중복 입력이 없는지 확인한다.
4. 병렬 서브테스트가 같은 변수를 쓴다
var results []string
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
results = append(results, Make(tt.in)) // 데이터 경합
})
}
-race로 잡힌다. 병렬 서브테스트는 서로 완전히 독립이어야 한다.
5. 기대값을 대상 코드로 계산한다
{"대문자", "GoLang", strings.ToLower("GoLang")}, // want를 코드로 만들었다
구현이 틀려도 기대값이 같이 틀린다. 기대값은 손으로 쓴 리터럴이어야 한다.
6. 표가 너무 커진다
케이스가 50개가 넘어가면 사람이 읽을 수 없다. 그쯤 되면 성질 기반 검증
(TestMakeProperties)이나 퍼징으로 넘어갈 때다.
정리
- 테이블 주도 테스트는 검증 로직 하나 + 데이터로서의 케이스다. 케이스 추가가 한 줄이 된다.
- 익명 구조체 슬라이스,
name을 첫 필드로, 변수 이름은tt. 이게 관례다. t.Run은 필수다. 격리, 이름 붙은 리포트,-run선택, 케이스별 병렬화를 전부 여기서 얻는다. 이름의 공백은_로 정규화된다.- Go 1.22부터
tt := tt는 필요 없다. - 실패 메시지에는 입력·실제·기대 셋이 다 들어간다.
t.Parallel은 I/O 테스트에 쓴다.t.Setenv/t.Chdir와는 함께 쓸 수 없다 — 패닉으로 막힌다.t.Cleanup은 LIFO로 돌고 도우미 함수 안에서 등록할 수 있다. 픽스처의 기반이다.- 어설션 라이브러리는 기본값이 아니다. 필요하면
go-cmp의cmp.Diff부터 본다. - 기대값은 손으로 쓴 리터럴이다. 대상 코드로 계산하지 않는다.
연습문제
-
Make에 언더스코어(_)를 하이픈으로 바꾸지 말고 그대로 두라는 요구가 추가됐다고 하자. 먼저TestMake에 실패하는 케이스를 추가하고 ({"언더스코어 유지", "a_b c", "a_b-c"}), 그다음 구현을 고쳐 통과시켜 보자.TestMakeProperties의 성질 검증 중 무엇을 함께 고쳐야 하는가? -
TestMakeProperties에t.Parallel()이 있다. 그 안에서 바깥의 슬라이스에 결과를 모으도록 바꾸고-race로 돌려 보자. 리포트가 어떤 두 지점을 가리키는가? 경합을 없애면서도 결과를 모으려면 어떻게 해야 하는가? (힌트: 7-9의 인덱스 슬롯 기법.) -
TestCleanupOrder의 서브테스트 안에서t.Fatal을 부르면 등록된 정리 함수가 실행되는가?t.Fatal대신 패닉을 일으키면 어떻게 되는가? 실험해 보고, 그 결과가 "정리는defer가 아니라t.Cleanup에 등록한다"는 조언에 어떤 근거를 주는지 정리해 보자.