LLM WikiAccess-protected knowledge portal
← 스터디 홈
6편 · 약 18분

테스트와 벤치마킹

Go 테스트는 “별도 프레임워크”가 아니라 툴체인의 일부다

Go에서 테스트를 배울 때 가장 먼저 이해해야 할 점은 testing 패키지와 go test가 언어 바깥의 부가 도구가 아니라 표준 개발 흐름이라는 것이다. 테스트 파일은 _test.go로 끝나고, 테스트 함수는 func TestXxx(t *testing.T) 형태를 따른다. 이 파일들은 일반 빌드에는 포함되지 않지만 go test를 실행하면 함께 컴파일되고 실행된다.

이 구조는 작지만 운영상 의미가 크다. 별도 테스트 러너를 고르기 전에 패키지 단위 테스트, 예제 테스트, 벤치마크, fuzz test, coverage를 같은 명령 체계로 다룰 수 있기 때문이다. 인프라 도구나 백엔드 서비스처럼 작은 패키지가 여러 개 모인 코드베이스에서는 “각 패키지가 자기 동작을 검증한다”는 기본 규칙이 유지보수 비용을 낮춘다.

*_test.go Test · Benchmark · Fuzz go test 컴파일 + 실행 단위 테스트 동작 회귀 방지 벤치마크 ns/op · allocs/op Fuzz 예상 못한 입력 탐색 운영 판단 릴리스 가능성 성능 변화 · 위험 입력
Go 테스트 도구 흐름

단위 테스트: public behavior를 고정하기

가장 기본적인 테스트는 “입력과 기대 결과”를 명확히 적는 것이다.

package normalize

import "testing"

func TestSlug(t *testing.T) {
    got := Slug("Hello, Go Test!")
    want := "hello-go-test"
    if got != want {
        t.Fatalf("Slug() = %q, want %q", got, want)
    }
}

Fatalf는 현재 테스트를 즉시 중단한다. 이후 검사가 의미 없거나 nil dereference 같은 부작용을 막아야 할 때 적합하다. 반대로 여러 독립 조건을 끝까지 보고 싶다면 Errorf를 쓴다. 둘의 차이를 모르면 실패 하나가 나머지 실패를 가려버리거나, 반대로 계속 실행하면 안 되는 테스트가 이어질 수 있다.

Go 테스트의 기본 단위는 패키지다. 같은 패키지에 테스트를 두면 unexported identifier까지 볼 수 있다.

package normalize

외부 사용자 관점의 black-box 테스트를 원하면 패키지 이름에 _test를 붙인다.

package normalize_test

이 방식은 exported API만 사용할 수 있으므로, 라이브러리의 실제 사용 경험을 더 잘 검증한다. 실무에서는 둘 중 하나만 고집하기보다, 내부 복잡도가 높은 패키지는 같은 패키지 테스트로 세부 경계를 잡고, 공개 API는 외부 테스트로 사용법을 고정하는 식으로 나눈다.

table-driven test와 subtest

Go 코드에서 여러 입력 케이스를 반복 검증할 때는 table-driven test가 자연스럽다.

func TestParseSize(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int64
        wantErr bool
    }{
        {name: "bytes", input: "512", want: 512},
        {name: "kilobytes", input: "64KB", want: 64 * 1024},
        {name: "empty", input: "", wantErr: true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseSize(tt.input)
            if (err != nil) != tt.wantErr {
                t.Fatalf("ParseSize() error = %v, wantErr %v", err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("ParseSize() = %d, want %d", got, tt.want)
            }
        })
    }
}

t.Run으로 각 케이스를 subtest로 만들면 실패 보고가 훨씬 선명해진다. go test -run 'TestParseSize/kilobytes'처럼 특정 케이스만 실행할 수도 있다. Go 블로그가 설명하듯 subtest는 table-driven test의 유지보수성을 높이고, 실패 격리와 명령행 선택을 가능하게 한다.

tt := tt는 오래된 Go 버전까지 고려한 방어적 패턴이다. Go 1.22부터 for loop variable semantics가 바뀌어 흔한 closure 캡처 버그가 줄었지만, 여러 버전을 지원하는 코드나 습관적으로 명시성을 선호하는 팀에서는 여전히 이 패턴을 볼 수 있다. 중요한 것은 subtest가 병렬 실행될 수 있거나 goroutine을 만들 수 있다는 점을 염두에 두고, 각 케이스의 상태를 독립적으로 다루는 것이다.

테스트 helper와 cleanup

테스트가 길어지는 흔한 이유는 setup과 teardown이 본문을 덮어버리기 때문이다. Go에서는 helper 함수와 t.Cleanup을 적극적으로 쓰는 편이 좋다.

func newTempConfig(t *testing.T, body string) string {
    t.Helper()

    dir := t.TempDir()
    path := filepath.Join(dir, "config.yaml")
    if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
        t.Fatalf("write config: %v", err)
    }
    return path
}

t.Helper()를 호출하면 실패 위치가 helper 내부가 아니라 helper를 부른 테스트 쪽으로 표시된다. t.TempDir()는 테스트가 끝난 뒤 임시 디렉터리를 정리한다. 직접 os.MkdirTempdefer os.RemoveAll을 반복하는 것보다 테스트 실패 시에도 수명 관리가 명확하다.

외부 자원을 붙이는 테스트에서는 t.Cleanup이 유용하다.

func startFakeServer(t *testing.T) *httptest.Server {
    t.Helper()

    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"ok":true}`))
    }))
    t.Cleanup(srv.Close)
    return srv
}

defer도 쓸 수 있지만, setup helper 안에서 정리 책임까지 등록할 수 있다는 점에서 t.Cleanup이 더 읽기 쉽다.

병렬 테스트는 속도보다 격리가 먼저다

t.Parallel()은 테스트를 병렬로 실행할 수 있게 한다. 하지만 병렬화는 단순히 시간을 줄이는 스위치가 아니다. 전역 변수, 현재 작업 디렉터리, process-wide environment, 공유 포트, 실제 DB 같은 자원을 건드리는 테스트는 병렬화했을 때 flake가 생기기 쉽다.

func TestNormalizeParallel(t *testing.T) {
    tests := []string{"A", "B", "C"}

    for _, input := range tests {
        input := input
        t.Run(input, func(t *testing.T) {
            t.Parallel()
            if Normalize(input) == "" {
                t.Fatalf("Normalize(%q) returned empty", input)
            }
        })
    }
}

좋은 병렬 테스트는 입력과 결과가 독립적이고, shared mutable state가 없다. 환경 변수를 바꿔야 한다면 t.Setenv를 쓰고 병렬 테스트와의 상호작용을 조심해야 한다. 데이터베이스 통합 테스트라면 테스트별 schema, transaction rollback, container isolation처럼 격리 전략을 먼저 설계한 뒤 병렬화를 켜야 한다.

coverage: 숫자가 아니라 빠진 경로를 찾는 도구

기본 coverage는 간단하다.

go test ./... -cover

파일로 남기고 함수별 또는 HTML로 보려면 다음 흐름을 쓴다.

go test ./... -coverprofile=coverage.out
go tool cover -func=coverage.out
go tool cover -html=coverage.out

coverage를 “몇 퍼센트 이상이면 안전하다”로만 읽으면 위험하다. 90% coverage라도 중요한 에러 처리 경로가 빠져 있을 수 있고, 60% coverage라도 핵심 business rule은 잘 고정되어 있을 수 있다. 운영 코드에서는 숫자보다 다음 질문이 더 중요하다.

  • 장애가 나면 치명적인 경로가 테스트되어 있는가?
  • timeout, context cancellation, retry exhaustion 같은 실패 경로가 검증되는가?
  • serialization, version parsing, 권한 판단처럼 작은 버그가 큰 사고가 되는 함수가 빠져 있지 않은가?
  • 새 PR이 기존 coverage를 의미 없이 낮추지는 않는가?

Go 1.20부터는 go build -coverGOCOVERDIR를 이용해 애플리케이션 binary를 실행하면서 integration coverage도 모을 수 있다. 단위 테스트로만 보기 어려운 CLI, daemon, 여러 프로세스 실행 경로를 검증할 때 유용하다. 이 경우에는 build, run, go tool covdata reporting의 세 단계로 생각하면 된다.

benchmark: 성능 주장을 측정 가능한 형태로 만들기

벤치마크 함수는 func BenchmarkXxx(b *testing.B) 형태이고, go test -bench로 실행한다.

func BenchmarkParseSize(b *testing.B) {
    for b.Loop() {
        _, _ = ParseSize("128MB")
    }
}

최신 Go의 testing 문서는 새 벤치마크에 b.Loop() 사용을 권장한다. loop body만 측정되고, setup은 자연스럽게 제외된다. 오래된 코드에서는 b.N을 많이 볼 수 있다.

func BenchmarkParseSizeLegacy(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = ParseSize("128MB")
    }
}

메모리 할당까지 보려면 다음처럼 실행한다.

go test -bench=. -benchmem ./...

결과의 ns/op, B/op, allocs/op는 각각 한 번의 operation당 시간, 할당 바이트, 할당 횟수를 의미한다. 성능 회귀를 볼 때는 한 번의 수치만 보지 말고 -count로 여러 번 실행한 뒤 비교한다.

go test -bench=BenchmarkParseSize -benchmem -count=10 ./...

벤치마크는 “빠르다”를 증명하는 도구라기보다 변경 전후를 같은 조건에서 비교하는 도구다. CPU governor, 백그라운드 작업, 테스트 머신 차이, compiler version에 따라 숫자가 달라질 수 있다. CI에서는 절대값 threshold보다 이전 기준선 대비 큰 회귀를 잡는 방식이 더 현실적이다.

sub-benchmark와 setup 분리

여러 입력 크기를 비교하려면 sub-benchmark를 쓴다.

func BenchmarkEncode(b *testing.B) {
    cases := []struct {
        name string
        n    int
    }{
        {"small", 10},
        {"medium", 1000},
        {"large", 100000},
    }

    for _, tc := range cases {
        b.Run(tc.name, func(b *testing.B) {
            values := makeValues(tc.n)
            b.ReportAllocs()
            for b.Loop() {
                _, _ = Encode(values)
            }
        })
    }
}

sub-benchmark를 쓰면 BenchmarkEncode/small, BenchmarkEncode/large처럼 결과가 분리된다. 큰 입력에서만 느린지, 작은 입력에서도 할당이 많은지 한눈에 볼 수 있다. setup 데이터를 매 iteration마다 만들지 않도록 위치를 조심해야 한다. 측정하고 싶은 것이 Encode인지, makeValues까지 포함한 전체 비용인지 명확히 해야 한다.

fuzz test: 예시 입력 너머의 속성을 검증하기

Go 1.18부터 fuzzing이 표준 도구에 들어왔다. fuzz test는 func FuzzXxx(f *testing.F) 형태를 쓰고, seed corpus를 넣은 뒤 f.Fuzz 안에서 속성을 검증한다.

func FuzzParseSizeRoundTrip(f *testing.F) {
    for _, seed := range []string{"1B", "64KB", "128MB"} {
        f.Add(seed)
    }

    f.Fuzz(func(t *testing.T, input string) {
        n, err := ParseSize(input)
        if err != nil {
            t.Skip()
        }
        if n < 0 {
            t.Fatalf("ParseSize(%q) returned negative value %d", input, n)
        }
    })
}

fuzzing에서는 exact output보다 property를 많이 검증한다. 예를 들어 “두 번 reverse하면 원래 문자열이 된다”, “valid UTF-8 입력은 처리 뒤에도 valid UTF-8이어야 한다”, “파서가 어떤 입력에서도 panic하지 않는다” 같은 조건이다. t.Skip()은 잘못된 입력을 실패로 취급하지 않고 버리는 데 쓸 수 있다.

실행은 두 단계로 생각하면 된다.

go test ./...              # seed corpus를 일반 테스트처럼 실행
go test -fuzz=Fuzz ./...   # fuzzing 실행

fuzzing이 실패 입력을 찾으면 testdata/fuzz/<FuzzName>/ 아래에 재현 가능한 seed를 저장할 수 있다. 이후 일반 go test에서도 그 seed가 회귀 테스트처럼 실행된다. 이 점이 중요하다. fuzzing은 일회성 랜덤 실험이 아니라, 발견한 버그를 재발 방지 자산으로 바꾸는 흐름이다.

HTTP 코드 테스트: 실제 네트워크 없이 handler 검증하기

이전 장에서 본 net/http 코드는 httptest와 잘 맞는다.

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
    rec := httptest.NewRecorder()

    HealthHandler(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
    }
    if strings.TrimSpace(rec.Body.String()) != "ok" {
        t.Fatalf("body = %q, want ok", rec.Body.String())
    }
}

이 방식은 handler 함수를 직접 호출하므로 빠르고 안정적이다. 반대로 middleware, routing, client behavior까지 같이 보려면 httptest.NewServer를 사용해 실제 HTTP server처럼 요청을 보낼 수 있다. 단위 테스트와 통합 테스트의 경계를 일부러 나눠야 한다. 모든 것을 NewServer로 돌리면 느리고, 모든 것을 handler 직접 호출로만 보면 실제 wire behavior를 놓칠 수 있다.

CI에서의 현실적인 기본 명령

작은 Go 프로젝트의 기본 검증은 다음 정도면 출발점이 된다.

go test ./...
go test -race ./...
go test ./... -coverprofile=coverage.out
go test -bench=. -benchmem ./...

다만 -race와 benchmark는 비용이 크다. 모든 PR마다 전부 실행할지, nightly나 release branch에서만 실행할지는 팀의 변경 빈도와 CI 예산에 맞춰 결정해야 한다. 중요한 것은 “로컬에서 빨리 도는 기본 테스트”와 “느리지만 깊게 보는 검증”을 구분하는 것이다.

인프라·백엔드 코드에서는 특히 다음 실패 경로를 테스트 목록에 넣는 습관이 도움이 된다.

  • context deadline과 cancellation
  • HTTP 4xx/5xx, timeout, malformed response
  • 파일 권한 오류와 디스크 full에 가까운 상황
  • version parsing과 boundary value
  • concurrent access와 shared state
  • retry 후 최종 실패

좋은 테스트는 코드를 많이 실행하는 테스트가 아니라, 운영에서 깨지면 아픈 가정을 명확히 고정하는 테스트다.

정리

Go의 테스트 생태계는 intentionally small하다. assertion DSL이 기본으로 크지 않고, test discovery도 naming convention에 기대며, benchmark와 coverage도 같은 go test 주변에 모여 있다. 이 단순함은 처음에는 투박해 보일 수 있지만, 장기 운영 코드에서는 장점이 된다. 테스트 방식이 패키지 구조와 툴체인에 자연스럽게 붙어 있기 때문이다.

실무에서는 다음 순서로 익히면 좋다.

  1. _test.go, TestXxx, go test ./...를 기본 습관으로 만든다.
  2. table-driven test와 t.Run으로 케이스를 선명하게 나눈다.
  3. t.Helper, t.TempDir, t.Cleanup, httptest로 setup 비용을 줄인다.
  4. coverage는 퍼센트보다 빠진 실패 경로를 찾는 데 쓴다.
  5. benchmark는 변경 전후 비교를 위한 측정 실험으로 다룬다.
  6. fuzzing은 사람이 생각하지 못한 입력을 회귀 테스트 자산으로 바꾸는 도구로 쓴다.

테스트는 코드의 장식이 아니라 운영 판단의 근거다. 배포 전에 “이 변경이 안전한가”를 사람이 감으로 답하지 않도록, Go는 표준 도구만으로도 꽤 단단한 검증 루프를 제공한다.

References

  • Go Packages, testing package: https://pkg.go.dev/testing
  • Go Tutorial, “Add a test”: https://go.dev/doc/tutorial/add-a-test
  • Go Blog, “Using Subtests and Sub-benchmarks”: https://go.dev/blog/subtests
  • Go Tutorial, “Getting started with fuzzing”: https://go.dev/doc/tutorial/fuzz
  • Go documentation, “Coverage profiling support for integration tests”: https://go.dev/doc/build-cover