테스트 기초
이 챕터에서 다루는 것
Go의 테스트는 표준 라이브러리 안에 있다. 별도 프레임워크를 설치하지 않고,
_test.go로 끝나는 파일에 func TestXxx(t *testing.T)를 쓰면 그게 테스트다.
이 챕터는 그 최소 규칙과 go test를 다룬다. 파트 7에서
미뤄 둔 "테스트가 끝난 뒤 고루틴이 남아 있으면 실패"도 여기서 갚는다.
:::note 이 레포에서 처음 쓰이는 테스트
파트 7까지의 예제에는 _test.go 파일이 하나도 없었다. 파트 8이 첫 테스트를 쓰고,
이후 모든 파트의 예제는 테스트를 함께 둔다. 여기서 정하는 형태가 그대로 관례가 된다.
:::
문제 — 예제 main으로는 부족하다
파트 7까지 예제를 확인한 방법은 main에서 출력을 찍고 눈으로 읽는 것이었다.
이 방식의 한계는 분명하다.
- 회귀를 못 잡는다. 코드를 고친 뒤 출력이 달라졌는지 사람이 매번 비교해야 한다.
- 에러 경로를 못 만든다. "디스크가 가득 찼을 때"를
main에서 재현할 방법이 없다. - 자동화가 안 된다. CI가 "잘 돌았다"를 판정할 기준이 없다.
go test는 이 셋을 전부 해결한다. 종료 코드로 성공·실패를 말하고, 실패한 위치와
이유를 출력하고, 코드 안에서 원하는 상황을 직접 만들게 해 준다.
규칙 다섯 개
- 파일 이름이 **
_test.go**로 끝난다. 이 파일은go build에 포함되지 않는다. - 함수 이름이
Test로 시작하고, 그다음 글자가 소문자가 아니다 (TestFoo는 되고Testfoo는 안 된다). - 서명이 **
func TestXxx(t *testing.T)**다. testing패키지를 import한다.- 실패는
t.Error/t.Fatal계열을 호출해서 알린다. 반환값이 아니다.
어설션 라이브러리도, 애노테이션도, 등록도 없다. go test가 파일을 훑어
조건에 맞는 함수를 찾아 부른다.
첫 테스트
대상 코드부터 본다.
// Package strutil은 문자열을 다듬는 작은 도우미들을 모아 둔 패키지다.
package strutil
import "unicode/utf8"
// Ellipsis는 잘린 문자열 끝에 붙는 말줄임표다.
const Ellipsis = "…"
// Truncate는 s를 최대 n개의 룬 길이로 줄인다.
// 실제로 잘린 경우 마지막 한 자리를 Ellipsis에 쓰므로,
// 반환값의 룬 개수는 언제나 n 이하다.
func Truncate(s string, n int) string {
if n <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= n {
return s
}
r := []rune(s)
return string(r[:n-1]) + Ellipsis
}
테스트는 같은 디렉터리, 같은 패키지에 둔다.
func TestTruncateKeepsShortString(t *testing.T) {
const in = "hello"
got := Truncate(in, 10)
if got != in {
t.Errorf("Truncate(%q, 10) = %q, want %q", in, got, in)
}
}
if 하나와 t.Errorf 하나다. assertEqual 같은 것이 없는 것은 기능이 빠진 게
아니라 의도다. Go 팀의 입장은 "테스트도 그냥 Go 코드다"이고, 그래서 새로 배울
DSL이 없다.
:::tip 실패 메시지의 표준 형태
함수(입력) = 실제, want 기대가 Go 표준 라이브러리 전체가 쓰는 형식이다.
%q(따옴표 붙은 문자열)를 쓰는 이유는 공백과 빈 문자열을 눈으로 구분하기
위해서다. %v로 찍으면 ""와 " "가 구별되지 않는다.
:::
Error와 Fatal
| 계열 | 하는 일 |
|---|---|
t.Log / t.Logf | 기록만 한다. 기본적으로는 실패한 테스트에서만 출력된다 |
t.Error / t.Errorf | 실패로 표시하고 계속 진행한다 |
t.Fatal / t.Fatalf | 실패로 표시하고 그 테스트 함수를 즉시 중단한다 |
t.Skip / t.Skipf | 이 테스트를 건너뛴 것으로 표시하고 중단한다 |
판단 기준은 하나다. 뒤에 오는 검증이 앞 검증의 성립을 전제하는가?
// Fatal은 뒤 검증이 앞 검증의 성립을 전제할 때 쓴다.
// 여기서는 잘린 문자열이 아니면 룬 개수를 세는 것 자체가 의미 없다.
func TestTruncateCutsByRuneCount(t *testing.T) {
const in = "가나다라마"
const want = "가나…"
got := Truncate(in, 3)
if got != want {
t.Fatalf("Truncate(%q, 3) = %q, want %q", in, got, want)
}
if n := utf8.RuneCountInString(got); n != 3 {
t.Errorf("결과 룬 개수 = %d, want 3 (문자열 %q)", n, got)
}
}
첫 검증이 실패하면 두 번째 검증의 출력은 소음일 뿐이다. 그래서 Fatal이다.
반대로 서로 독립적인 검증 여럿을 한 함수에 둘 때는 Error를 써서 한 번에
전부 보고받는 편이 낫다. 고칠 것이 세 개인데 세 번 돌려야 한다면 낭비다.
:::danger Fatal은 테스트 고루틴에서만 부른다
t.Fatal은 내부적으로 runtime.Goexit을 호출해 현재 고루틴을 끝낸다.
테스트가 띄운 다른 고루틴에서 부르면 그 고루틴만 죽고 테스트는 계속 진행되어,
실패가 묻히거나 엉뚱한 곳에서 멈춘다. 다른 고루틴에서는 t.Error를 쓰거나,
값을 채널로 보내 테스트 고루틴에서 판정한다.
:::
t.Helper — 실패 위치를 호출한 줄로
검증 코드가 반복되면 함수로 뺀다. 그런데 그냥 빼면 실패 위치가 전부 그 도우미 함수 안의 한 줄로 보고되어, 어느 케이스가 깨졌는지 알 수 없게 된다.
t.Helper()가 그것을 고친다.
// assertTruncate는 검증 도우미다. t.Helper()를 부르면 실패 위치가
// 이 함수 안이 아니라 호출한 줄로 보고된다.
func assertTruncate(t *testing.T, in string, n int, want string) {
t.Helper()
if got := Truncate(in, n); got != want {
t.Errorf("Truncate(%q, %d) = %q, want %q", in, n, got, want)
}
}
func TestTruncateEdgeCases(t *testing.T) {
assertTruncate(t, "hello", 0, "")
assertTruncate(t, "hello", -1, "")
assertTruncate(t, "hello", 1, "…")
assertTruncate(t, "", 5, "")
assertTruncate(t, "hello", 5, "hello")
}
규칙은 단순하다. *testing.T를 받아 검증하는 함수는 첫 줄에서 t.Helper()를
부른다. 잊어버리면 실패 리포트가 쓸모없어진다.
*testing.T는 관례상 첫 번째 인자다. context.Context와 같은 자리 규칙이다.
외부 테스트 패키지 foo_test
같은 디렉터리에 패키지 이름이 다른 테스트 파일을 둘 수 있다. 이름은 반드시
원래패키지_test여야 한다.
// 이 파일의 패키지 이름은 strutil_test다. 같은 디렉터리에 있지만
// 별개의 패키지로 컴파일되므로, 사용자와 똑같이 import해서 써야 한다.
// 내부 식별자에는 접근할 수 없다.
package strutil_test
import (
"fmt"
"testing"
"example.com/testing-and-quality/01-basics/strutil"
)
func TestTruncatePublicAPI(t *testing.T) {
got := strutil.Truncate("abcdef", 4)
want := "abc" + strutil.Ellipsis
if got != want {
t.Errorf("Truncate(\"abcdef\", 4) = %q, want %q", got, want)
}
}
두 방식을 쓰는 이유가 각각 있다.
같은 패키지 (package strutil) | 외부 (package strutil_test) | |
|---|---|---|
| 비공개 식별자 | 접근 가능 | 불가능 |
| 검증 대상 | 내부 로직, 헬퍼 함수 | 공개 API의 사용감 |
| 순환 import | 생길 수 있다 | 생기지 않는다 |
공개 API 테스트는 외부 패키지에 두는 쪽이 낫다. 사용자와 같은 제약을 받으므로
"쓰기 불편한 API"가 테스트를 쓸 때 바로 드러난다. 순환 import 문제도 해결한다 —
net/http를 테스트하려면 net/http/httptest가 필요한데, 그건 다시 net/http를
import한다. 외부 테스트 패키지가 아니면 성립하지 않는다.
두 파일이 한 디렉터리에 공존해도 된다. 이 예제가 그렇다.
Example 함수
Example로 시작하는 함수는 문서 예제이자 테스트다. 마지막의 // Output: 주석과
실제 표준 출력이 다르면 실패한다.
// Example로 시작하는 함수는 문서 예제이자 테스트다.
// 마지막 Output 주석과 표준 출력이 다르면 실패한다.
func ExampleTruncate() {
fmt.Println(strutil.Truncate("Go는 단순하다", 4))
fmt.Println(strutil.Truncate("Go", 4))
// Output:
// Go는…
// Go
}
이름 규칙에 따라 문서의 어느 자리에 붙을지가 정해진다.
| 함수 이름 | 붙는 곳 |
|---|---|
Example | 패키지 전체 |
ExampleTruncate | Truncate 함수 |
ExampleClient_Do | Client 타입의 Do 메서드 |
ExampleTruncate_korean | Truncate의 추가 예제 (접미사는 소문자로 시작) |
// Output: 주석이 없으면 컴파일만 되고 실행되지 않는다. 출력 순서가
보장되지 않는 경우(맵 순회 등)에는 // Unordered output:을 쓴다.
go test 실행
cd examples/08-testing
go test ./01-basics/strutil
ok example.com/testing-and-quality/01-basics/strutil 0.450s
-v를 붙이면 개별 테스트가 전부 보인다.
go test -v ./01-basics/strutil
--- strutil 테스트 시작
=== RUN TestTruncateKeepsShortString
--- PASS: TestTruncateKeepsShortString (0.00s)
=== RUN TestTruncateCutsByRuneCount
--- PASS: TestTruncateCutsByRuneCount (0.00s)
=== RUN TestTruncateEdgeCases
--- PASS: TestTruncateEdgeCases (0.00s)
=== RUN TestTruncatePublicAPI
--- PASS: TestTruncatePublicAPI (0.00s)
=== RUN ExampleTruncate
--- PASS: ExampleTruncate (0.00s)
PASS
--- strutil 테스트 끝
ok example.com/testing-and-quality/01-basics/strutil 0.156s
앞뒤의 --- strutil 테스트 … 두 줄은 바로 아래에서 다룰 TestMain이 찍은 것이다.
자주 쓰는 플래그
| 플래그 | 하는 일 |
|---|---|
-v | 개별 테스트 이름과 t.Log 출력을 전부 보여 준다 |
-run 정규식 | 이름이 매치되는 테스트만 실행한다 |
-count N | N번 반복 실행한다. -count=1은 캐시를 무효화하는 관용구다 |
-failfast | 첫 실패에서 멈춘다 |
-timeout d | 전체 실행이 d를 넘으면 패닉으로 죽인다 (기본 10분) |
-shuffle=on | 테스트 실행 순서를 섞는다. 순서 의존을 잡아낸다 |
-race | data race detector를 켠다 (8-7) |
-cover | 커버리지를 잰다 (8-4) |
-run의 정규식은 앵커가 없다
이것이 첫 함정이다.
go test -v -run 'TestMake/^대문자' ./02-table-driven/slug
=== RUN TestMake
=== RUN TestMake/대문자_변환
--- PASS: TestMake (0.00s)
--- PASS: TestMake/대문자_변환 (0.00s)
=== RUN TestMakeProperties
--- PASS: TestMakeProperties (0.00s)
=== RUN TestMakeFromMap
--- PASS: TestMakeFromMap (0.00s)
PASS
TestMakeProperties와 TestMakeFromMap까지 돌았다. TestMake가 그 이름들의
부분 문자열이기 때문이다. 정확히 하나만 고르려면 앵커를 붙인다.
go test -v -run '^TestMake$/^대문자' ./02-table-driven/slug
=== RUN TestMake
=== RUN TestMake/대문자_변환
--- PASS: TestMake (0.00s)
--- PASS: TestMake/대문자_변환 (0.00s)
PASS
/로 구분된 각 조각이 각 단계(테스트 → 서브테스트)에 차례로 적용된다.
결과 캐싱
같은 코드에 같은 플래그로 다시 돌리면 실행하지 않고 캐시된 결과를 보여 준다.
ok example.com/testing-and-quality/01-basics/strutil (cached)
캐시는 소스, 플래그, 참조한 환경 변수와 파일이 모두 같을 때만 적중한다.
강제로 다시 돌리려면 -count=1을 붙인다. -count=1이 관용구가 된 이유는
"캐시 끄기" 전용 플래그가 없기 때문이다.
타임아웃
go test -run TestSlow -timeout 1s .
panic: test timed out after 1s
running tests:
TestSlow (1s)
goroutine 35 [running]:
testing.(*M).startAlarm.func1()
/usr/local/go/src/testing/testing.go:2802 +0x2cc
running tests: 목록이 핵심이다. 어느 테스트가 매달려 있었는지, 그리고
이어지는 전체 고루틴 덤프에서 어디에 블록됐는지가 보인다. CI에서 테스트가
멈췄을 때 가장 먼저 읽어야 할 부분이다.
TestMain — 패키지 전체의 진입점
func TestMain(m *testing.M)을 선언하면 go test가 개별 테스트 대신 이 함수를
부른다. 테스트를 실제로 돌리는 것은 m.Run()이다.
// TestMain은 패키지의 모든 테스트를 감싸는 진입점이다.
// m.Run()을 호출하지 않으면 테스트가 하나도 돌지 않는다.
func TestMain(m *testing.M) {
fmt.Println("--- strutil 테스트 시작")
code := m.Run()
fmt.Println("--- strutil 테스트 끝")
os.Exit(code)
}
용도는 패키지 단위의 준비와 정리다. 테스트용 데이터베이스 컨테이너 띄우기, 임시 디렉터리 만들기, 전역 설정 초기화 같은 것들이다.
:::warning TestMain은 테스트 바이너리당 하나뿐이다
같은 패키지에 두 개를 선언하면 컴파일 에러다. 내부 테스트 파일과 외부
_test 패키지 파일은 하나의 테스트 바이너리로 링크되므로, 둘 중 한 곳에만
있어야 한다.
:::
:::tip os.Exit는 이제 선택이다
예전에는 os.Exit(m.Run())이 필수였다. 지금은 TestMain이 그냥 반환하면
m.Run()의 결과가 종료 코드로 쓰인다. 다만 os.Exit를 직접 부르면
defer가 실행되지 않는다는 점은 여전하다. 정리 코드를 defer에 두려면
os.Exit를 쓰지 말고 반환한다.
:::
파트 7의 빚 — 고루틴 누수를 테스트로 잡기
7-1에서 "테스트가 끝난 뒤 고루틴이 남아
있으면 실패시키는 검사가 실무에서 가장 효과적"이라고 하고 미뤄 뒀다. TestMain이
그 자리다.
대상은 누수하기 쉬운 함수다.
// Watch는 src를 끝까지 읽어 개수를 세는 고루틴을 띄우고,
// 그 개수를 받을 채널을 돌려준다.
//
// out에 버퍼가 없으므로 호출자가 결과를 받지 않으면 고루틴이
// 송신 지점에서 영원히 멈춘다 — 전형적인 고루틴 누수다.
func Watch(src <-chan int) <-chan int {
out := make(chan int)
go func() {
n := 0
for range src {
n++
}
out <- n // 수신자가 없으면 여기서 영원히 대기한다
}()
return out
}
검사기는 이렇게 생겼다.
// TestMain은 모든 테스트가 끝난 뒤 고루틴 수가 시작 시점으로
// 돌아왔는지 확인한다. 돌아오지 않았으면 종료 코드를 1로 바꾼다.
func TestMain(m *testing.M) {
before := runtime.NumGoroutine()
code := m.Run()
// 테스트가 이미 실패했다면 누수 보고는 소음이다.
if code == 0 {
if after, ok := settleTo(before, 2*time.Second); !ok {
fmt.Fprintf(os.Stderr, "고루틴 누수: 시작 %d개 → 종료 %d개\n", before, after)
buf := make([]byte, 1<<16)
buf = buf[:runtime.Stack(buf, true)]
os.Stderr.Write(buf)
code = 1
}
}
os.Exit(code)
}
// settleTo는 고루틴 수가 want 이하로 내려갈 때까지 최대 limit만큼 기다린다.
// 끝나는 중인 고루틴이 스케줄될 시간을 주기 위한 것이다 — 한 번만 세면
// 정상 종료 중인 고루틴을 누수로 오인한다.
func settleTo(want int, limit time.Duration) (int, bool) {
deadline := time.Now().Add(limit)
n := runtime.NumGoroutine()
for n > want && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
n = runtime.NumGoroutine()
}
return n, n <= want
}
settleTo의 재시도 루프가 이 검사기의 핵심이다. m.Run()이 반환한 직후에
한 번만 세면, 정상적으로 끝나는 중인 고루틴이 아직 스케줄되지 않아 누수로
오인된다. 이 검사기 자체가 플래키해지는 것이다.
누수 재현용 테스트는 빌드 태그로 분리해 뒀다. 기본 실행에서는 빠진다.
//go:build leakdemo
// 이 파일은 일부러 고루틴을 누수시킨다. 기본 빌드에서 제외해서
// `go test ./...`가 초록색으로 남게 하고, 보고 싶을 때만 켠다.
//
// go test -tags leakdemo ./01-basics/leaky
package leaky
import "testing"
func TestWatchLeaksWhenResultIgnored(t *testing.T) {
src := make(chan int)
Watch(src) // 결과를 받지 않는다 → 내부 고루틴이 송신에서 멈춘다
close(src)
}
go test -tags leakdemo ./01-basics/leaky
PASS
고루틴 누수: 시작 1개 → 종료 2개
goroutine 1 [running]:
example.com/testing-and-quality/01-basics/leaky.TestMain(0x100487f18?)
/Users/sgn04088/golang-lecture/examples/08-testing/01-basics/leaky/watch_test.go:23 +0x148
main.main()
_testmain.go:52 +0x88
goroutine 5 [chan send]:
example.com/testing-and-quality/01-basics/leaky.Watch.func1()
/Users/sgn04088/golang-lecture/examples/08-testing/01-basics/leaky/watch.go:17 +0x60
created by example.com/testing-and-quality/01-basics/leaky.Watch in goroutine 4
/Users/sgn04088/golang-lecture/examples/08-testing/01-basics/leaky/watch.go:12 +0x78
FAIL example.com/testing-and-quality/01-basics/leaky 2.342s
PASS가 먼저 찍히는 것에 주목한다. 테스트는 전부 통과했다. 누수는 그다음에
잡혔고, 종료 코드가 바뀌어 FAIL이 됐다. goroutine 5 [chan send]와
created by … Watch in goroutine 4가 범인을 정확히 가리킨다 — 7-6에서 읽었던
스택 덤프 형식 그대로다.
:::info 이 검사기의 한계
NumGoroutine()은 런타임 내부 고루틴도 센다. 절대값이 아니라 증감만 본다.- 어느 테스트가 누수시켰는지는 알려 주지 않는다.
-run으로 좁혀 가며 찾는다. - 실무에서는
go.uber.org/goleak이 같은 일을 더 정교하게 한다 (goleak.VerifyTestMain(m)한 줄). 원리는 위와 같으니, 직접 써 보고 나면 라이브러리가 무엇을 대신해 주는지가 보인다. - Go 1.26에는 실험 기능인 고루틴 누수 프로파일(
GOEXPERIMENT=goroutineleakprofile)도 있다. 프로파일링은 파트 12의 주제다. :::
Watch의 고친 버전은 버퍼 하나면 된다.
// WatchBuffered는 같은 일을 하지만 버퍼 1을 준다.
// 호출자가 결과를 버려도 고루틴은 값을 넣고 정상 종료한다.
func WatchBuffered(src <-chan int) <-chan int {
out := make(chan int, 1)
go func() {
n := 0
for range src {
n++
}
out <- n
}()
return out
}
흔히 하는 실수
1. 도우미 함수에서 t.Helper()를 빼먹는다
실패 리포트의 모든 줄 번호가 도우미 함수 안의 한 줄을 가리킨다. 어느 케이스가
깨졌는지 알 수 없다. *testing.T를 받는 함수는 첫 줄이 t.Helper()다.
2. 다른 고루틴에서 t.Fatal을 부른다
runtime.Goexit이 그 고루틴만 끝내고 테스트는 계속된다. 다른 고루틴에서는
t.Error를 쓴다.
3. 테스트 사이에 상태를 공유한다
패키지 변수에 캐시나 카운터를 두고 여러 테스트가 건드리면 실행 순서에 따라
결과가 달라진다. -shuffle=on으로 잡을 수 있다.
go test -shuffle=on -count=1 -v ./02-table-driven/slug
-test.shuffle 1786433063356696000
맨 앞에 찍히는 시드를 -shuffle=1786433063356696000으로 다시 주면 같은 순서를
재현할 수 있다.
4. 테스트가 끝난 뒤에 t를 쓴다
go func() {
t.Log("백그라운드 작업 완료") // 테스트가 이미 끝났으면 패닉
}()
t.Cleanup이나 채널로 종료를 기다린 뒤에 보고한다.
5. 캐시된 결과를 실제 실행으로 착각한다
(cached)가 붙어 있으면 코드가 돌지 않은 것이다. 외부 상태(DB, 네트워크)에
의존하는 테스트라면 -count=1을 붙인다.
6. _test.go 파일에 프로덕션 코드를 둔다
_test.go는 go build에 포함되지 않으므로, 거기 선언한 타입이나 함수는
패키지 사용자에게 보이지 않는다. 반대로 테스트 전용 도우미를 일반 파일에 두면
프로덕션 바이너리에 딸려 들어간다. 경계를 지킨다.
정리
_test.go파일에func TestXxx(t *testing.T)를 쓰면 테스트다. 프레임워크도 어설션 라이브러리도 필요 없다.Error는 계속,Fatal은 중단. 뒤 검증이 앞 검증에 의존하면Fatal.*testing.T를 받는 도우미 함수는 첫 줄에t.Helper().- 실패 메시지는
함수(입력) = 실제, want 기대형식으로, 문자열은%q로 찍는다. - 공개 API는
package foo_test에서 테스트한다. 순환 import를 피하고 API의 사용감을 검증한다. Example함수는 문서이자 테스트다.// Output:주석이 있어야 실행된다.-run의 정규식에는 앵커가 없다.^TestFoo$형태로 쓴다.-count=1이 캐시를 끄는 관용구다.TestMain으로 고루틴 누수를 잡는다. 한 번만 세지 말고 잠깐 기다렸다 센다.
연습문제
-
Truncate에n이 문자열 길이보다 정확히 1 작을 때의 테스트를 추가해 보자.Truncate("abc", 2)는 무엇을 반환해야 하는가? 지금 구현이 그렇게 하는가? 그다음Ellipsis를"..."(마침표 세 개)로 바꾸면 어떤 테스트가 깨지는가? "반환값의 룬 개수는 언제나 n 이하"라는 문서 약속은 여전히 지켜지는가? -
assertTruncate에서t.Helper()줄을 지우고 일부러 실패하는 케이스를 하나 넣어 보자. 실패 리포트의 줄 번호가 어떻게 달라지는가? 그 상태에서 어느 케이스가 깨졌는지 알아낼 수 있는가? -
leaky패키지에Watch를 두 번 부르고 한쪽 결과만 받는 테스트를 빌드 태그leakdemo아래에 추가해 보자. 스택 덤프에 고루틴이 몇 개 찍히는가?settleTo의 대기 시간을 0으로 바꾸면 검사기 자체가 어떻게 되는가? (WatchBuffered만 쓰는 테스트로 여러 번 돌려 보자.)