PHpullh

GO · 심층 가이드

Go 테스트 완전 정리

표준 testing 패키지만으로 테이블 테스트부터 병렬 실행, httptest, 레이스 디텍터, 퍼즈 테스트까지 끌고 가는 19개 주제를 순서대로 담았습니다.

주제 19개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Go의 테스트는 별도 러너나 애노테이션 없이 go test 한 줄로 끝납니다. 파일 이름이 _test.go로 끝나고 함수가 Test로 시작하면 그게 전부입니다. 대신 어서션 문법이 언어에 없어서, 조건을 직접 비교하고 t.Errorf로 실패 메시지를 만드는 코드가 반복됩니다. 이 반복을 줄이려고 자연스럽게 굳은 관용구가 테이블 드리븐 테스트이고, Go 코드베이스에서 테스트가 대부분 비슷하게 생긴 이유도 여기 있습니다.

testing 패키지와 테이블 드리븐 테스트로 기본형을 잡은 뒤, 서브테스트로 케이스마다 이름을 붙여 -run 필터가 먹히게 만드는 단계가 다음입니다. 여기까지 오면 병렬 테스트t.Parallel() 한 줄로 붙습니다. HTTP 코드라면 httptest 패키지인터페이스 기반 테스트를 함께 보고, 출력이 크고 구조적인 코드에는 골든 파일 테스트가 유지보수하기 편합니다.

병렬 테스트에는 오래된 함정이 둘 있습니다. 하나는 루프 변수 캡처인데, Go 1.22부터 for 반복마다 변수가 새로 만들어지므로 예전처럼 tc := tc를 복사할 필요가 없어졌습니다. 다만 그 이전 버전으로 빌드되는 코드에서는 여전히 필수입니다. 다른 하나는 실행 시점입니다. t.Parallel()을 부른 서브테스트는 부모 함수가 반환한 뒤에 실행되므로, 부모에 걸어 둔 defer 정리 코드가 서브테스트보다 먼저 돌아 리소스를 치워 버립니다. 정리는 t.Cleanup으로 옮기는 편이 안전합니다.

01testing 패키지 & 테이블 드리븐 테스트

Go 내장 테스트 프레임워크로 표 기반 테스트를 작성합니다.

Go code

// calculator_test.go
package main

import (
	"testing"
)

func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
	if b == 0 { return 0, fmt.Errorf("division by zero") }
	return a / b, nil
}

// 기본 테스트
func TestAdd(t *testing.T) {
	got  := Add(2, 3)
	want := 5
	if got != want {
		t.Errorf("Add(2,3) = %d; want %d", got, want)
	}
}

// 테이블 드리븐 테스트 (Go 관용구)
func TestAddTable(t *testing.T) {
	tests := []struct {
		name string
		a, b int
		want int
	}{
		{"양수+양수", 2, 3, 5},
		{"음수포함", -1, 1, 0},
		{"둘다음수", -3, -2, -5},
		{"영포함", 0, 5, 5},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Add(tt.a, tt.b)
			if got != tt.want {
				t.Errorf("Add(%d,%d) = %d; want %d",
					tt.a, tt.b, got, tt.want)
			}
		})
	}
}

// 벤치마크
func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(100, 200)
	}
}

// 예제 함수 (문서화 + 테스트)
func ExampleAdd() {
	fmt.Println(Add(1, 2))
	// Output: 3
}

// go test ./...        — 테스트 실행
// go test -v ./...     — 상세 출력
// go test -run Table   — 특정 테스트만
// go test -bench=.     — 벤치마크 실행
// go test -cover ./... — 커버리지
알아두면 좋은 점

t.Run()으로 서브테스트를 만들면 go test -run TestAddTable/양수처럼 특정 케이스만 실행할 수 있습니다.

자주 하는 실수

t.Error()는 테스트를 계속 실행하고, t.Fatal()은 즉시 중단합니다. 이후 로직이 nil 포인터에 의존할 때는 t.Fatal()을 사용하세요.

02testify &amp; Mock 테스트

외부 라이브러리 testify로 더 읽기 쉬운 테스트를 작성합니다.

Go code

// go get github.com/stretchr/testify
package main

import (
	"testing"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/mock"
)

// 인터페이스 정의
type UserRepo interface {
	FindByID(id int) (*User, error)
	Save(u *User) error
}

// Mock 구현
type MockUserRepo struct {
	mock.Mock
}

func (m *MockUserRepo) FindByID(id int) (*User, error) {
	args := m.Called(id)
	return args.Get(0).(*User), args.Error(1)
}

func (m *MockUserRepo) Save(u *User) error {
	return m.Called(u).Error(0)
}

// 서비스
type UserService struct{ repo UserRepo }

func (s *UserService) GetUser(id int) (*User, error) {
	return s.repo.FindByID(id)
}

// 테스트
func TestGetUser(t *testing.T) {
	mockRepo := new(MockUserRepo)
	expected := &User{ID: 1, Name: "Alice"}

	// Mock 설정
	mockRepo.On("FindByID", 1).Return(expected, nil)

	svc := &UserService{repo: mockRepo}
	user, err := svc.GetUser(1)

	// assert — 실패해도 계속
	assert.NoError(t, err)
	assert.Equal(t, expected.Name, user.Name)
	assert.NotNil(t, user)

	// require — 실패 시 즉시 중단
	require.NotNil(t, user)

	// Mock 호출 검증
	mockRepo.AssertExpectations(t)
	mockRepo.AssertCalled(t, "FindByID", 1)
}
알아두면 좋은 점

require는 테스트를 즉시 종료합니다. nil 포인터 역참조처럼 이후 로직이 안전하지 않을 때 사용하세요.

자주 하는 실수

assert.Equal(t, expected, actual)에서 순서가 중요합니다: 첫 번째가 expected, 두 번째가 actual입니다. 반대로 쓰면 에러 메시지가 혼란스러워집니다.

03min/max 내장 함수 (Go 1.21+)

드디어 추가된 min(), max() 내장 함수

Go code

<span class="cm">// min/max 내장 함수 (Go 1.21+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("min/max 내장 함수 (Go 1.21+)") }
알아두면 좋은 점

GO 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

04벤치마크 테스트 — testing.B

Go의 testing.B는 성능 벤치마크를 내장 지원합니다. go test -bench로 함수 실행 시간, 메모리 할당을 정밀 측정하고, b.Run으로 서브 벤치마크를 구성할 수 있습니다.

Go code

package main

import (
	"fmt"
	"strings"
	"testing"
)

// 벤치마크 대상 함수들
func ConcatPlus(strs []string) string {
	result := ""
	for _, s := range strs {
		result += s
	}
	return result
}

func ConcatBuilder(strs []string) string {
	var b strings.Builder
	for _, s := range strs {
		b.WriteString(s)
	}
	return b.String()
}

func ConcatJoin(strs []string) string {
	return strings.Join(strs, "")
}

// 벤치마크 테스트 (파일명: concat_test.go)
func BenchmarkConcatPlus(b *testing.B) {
	strs := make([]string, 100)
	for i := range strs {
		strs[i] = "hello"
	}
	b.ResetTimer() // 준비 시간 제외
	for i := 0; i < b.N; i++ {
		ConcatPlus(strs)
	}
}

func BenchmarkConcatBuilder(b *testing.B) {
	strs := make([]string, 100)
	for i := range strs {
		strs[i] = "hello"
	}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ConcatBuilder(strs)
	}
}

func BenchmarkConcatJoin(b *testing.B) {
	strs := make([]string, 100)
	for i := range strs {
		strs[i] = "hello"
	}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ConcatJoin(strs)
	}
}

// 서브 벤치마크로 크기별 비교
func BenchmarkConcat(b *testing.B) {
	sizes := []int{10, 100, 1000}
	for _, size := range sizes {
		strs := make([]string, size)
		for i := range strs {
			strs[i] = "x"
		}
		b.Run(fmt.Sprintf("Plus/%d", size), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				ConcatPlus(strs)
			}
		})
		b.Run(fmt.Sprintf("Builder/%d", size), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				ConcatBuilder(strs)
			}
		})
	}
}

// 메모리 할당 추적
func BenchmarkMemory(b *testing.B) {
	strs := make([]string, 100)
	for i := range strs {
		strs[i] = "test"
	}
	b.ReportAllocs() // 메모리 할당 보고
	for i := 0; i < b.N; i++ {
		ConcatBuilder(strs)
	}
}

// 실행: go test -bench=. -benchmem -count=3
func main() {
	fmt.Println("벤치마크 실행 방법:")
	fmt.Println("  go test -bench=. -benchmem")
	fmt.Println("  go test -bench=BenchmarkConcat -benchtime=5s")
	fmt.Println("  go test -bench=. -cpuprofile=cpu.prof")
}
알아두면 좋은 점

b.ResetTimer()로 준비 작업 시간을 제외하고, b.ReportAllocs()로 메모리 할당 횟수를 측정하세요. -benchtime=5s로 측정 시간을 늘리면 더 안정적인 결과를 얻습니다.

자주 하는 실수

벤치마크 루프 안에서 결과를 사용하지 않으면 컴파일러가 최적화로 코드를 제거할 수 있습니다. 패키지 레벨 변수에 결과를 저장하여 최적화를 방지하세요.

05테이블 기반 테스트

Go의 핵심 테스트 패턴. 테스트 케이스를 테이블로 정의하여 반복적인 코드를 줄입니다.

Go code

package main

import (
	"fmt"
	"testing"
)

func Add(a, b int) int { return a + b }

func TestAdd(t *testing.T) {
	tests := []struct {
		name     string
		a, b     int
		expected int
	}{
		{"양수 덧셈", 2, 3, 5},
		{"음수 포함", -1, 5, 4},
		{"제로", 0, 0, 0},
		{"큰 수", 1000000, 1, 1000001},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Add(tt.a, tt.b)
			if got != tt.expected {
				t.Errorf("Add(%d, %d) = %d, want %d",
					tt.a, tt.b, got, tt.expected)
			}
		})
	}
}

func main() {
	// go test -v -run TestAdd
	fmt.Println("테이블 기반 테스트: go test -v")
}
알아두면 좋은 점

테이블 기반 테스트에 t.Run을 사용하면 각 케이스가 서브테스트로 실행되어 실패 시 어떤 케이스인지 명확히 알 수 있습니다.

자주 하는 실수

테스트 함수 이름은 반드시 Test로 시작해야 합니다. 소문자로 시작하면 테스트 러너가 인식하지 못합니다.

06서브테스트

t.Run으로 서브테스트를 만들어 테스트를 구조화합니다. 개별 실행과 병렬 처리가 가능합니다.

Go code

package main

import (
	"fmt"
	"testing"
)

func TestUserService(t *testing.T) {
	// 셋업
	// db := setupTestDB(t)

	t.Run("Create", func(t *testing.T) {
		t.Run("유효한 입력", func(t *testing.T) {
			// 테스트 코드
		})
		t.Run("중복 이메일", func(t *testing.T) {
			// 테스트 코드
		})
	})

	t.Run("Find", func(t *testing.T) {
		t.Run("존재하는 ID", func(t *testing.T) {
			// 테스트 코드
		})
		t.Run("존재하지 않는 ID", func(t *testing.T) {
			// 테스트 코드
		})
	})

	// 병렬 서브테스트
	t.Run("Parallel", func(t *testing.T) {
		tests := []string{"test1", "test2", "test3"}
		for _, name := range tests {
			t.Run(name, func(t *testing.T) {
				t.Parallel() // 병렬 실행
				// 독립적인 테스트
			})
		}
	})
}

func main() {
	// go test -v -run TestUserService/Create/유효한_입력
	fmt.Println("서브테스트: go test -v -run TestX/SubTest")
}
알아두면 좋은 점

go test -run TestX/SubName으로 특정 서브테스트만 실행할 수 있습니다. 한글 이름도 지원됩니다.

자주 하는 실수

t.Parallel()을 사용할 때 외부 변수를 캡처하면 레이스 컨디션이 발생합니다. Go 1.22+에서는 루프 변수가 안전합니다.

07벤치마크 심화

testing.B로 함수 성능을 측정합니다. 메모리 할당량과 알고리즘 비교에 사용됩니다.

Go code

package main

import (
	"fmt"
	"strings"
	"testing"
)

// 문자열 연결 벤치마크 비교
func BenchmarkConcat(b *testing.B) {
	b.Run("Plus", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			s := ""
			for j := 0; j < 100; j++ {
				s += "x"
			}
		}
	})

	b.Run("Builder", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			var sb strings.Builder
			for j := 0; j < 100; j++ {
				sb.WriteString("x")
			}
			_ = sb.String()
		}
	})

	b.Run("Join", func(b *testing.B) {
		parts := make([]string, 100)
		for i := range parts { parts[i] = "x" }
		b.ResetTimer() // 준비 시간 제외
		for i := 0; i < b.N; i++ {
			_ = strings.Join(parts, "")
		}
	})
}

func main() {
	// go test -bench=. -benchmem -count=5
	fmt.Println("벤치마크: go test -bench=BenchmarkConcat -benchmem")
}
알아두면 좋은 점

-benchmem 플래그로 할당 횟수와 크기를 확인하세요. b.ReportAllocs()도 동일한 효과입니다.

자주 하는 실수

벤치마크 루프 밖에서 데이터를 준비하면 준비 시간이 포함됩니다. b.ResetTimer()로 타이머를 초기화하세요.

08테스트 헬퍼

t.Helper()와 테스트 유틸리티로 반복적인 테스트 코드를 줄입니다.

Go code

package main

import (
	"fmt"
	"testing"
)

// 테스트 헬퍼
func assertEqual[T comparable](t *testing.T, got, want T) {
	t.Helper() // 에러 위치를 호출자로 표시
	if got != want {
		t.Errorf("got %v, want %v", got, want)
	}
}

func assertNoError(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatalf("예상치 못한 에러: %v", err)
	}
}

func assertError(t *testing.T, err error) {
	t.Helper()
	if err == nil {
		t.Fatal("에러를 기대했지만 nil")
	}
}

// 테스트 픽스처
func setupTest(t *testing.T) func() {
	t.Helper()
	fmt.Println("테스트 셋업")
	// DB 연결, 임시 파일 생성 등

	return func() {
		fmt.Println("테스트 정리")
		// 리소스 해제
	}
}

func TestExample(t *testing.T) {
	cleanup := setupTest(t)
	defer cleanup()
	// 또는 Go 1.14+: t.Cleanup(func() { ... })

	assertEqual(t, 1+1, 2)
	assertNoError(t, nil)
}

func main() {
	fmt.Println("t.Helper()로 에러 위치를 정확히 표시")
}
알아두면 좋은 점

t.Cleanup(fn)은 Go 1.14+에서 사용 가능하며, 테스트 종료 시 자동으로 정리 함수를 실행합니다.

자주 하는 실수

t.Helper()를 호출하지 않으면 에러 메시지가 헬퍼 함수 내부를 가리켜 디버깅이 어렵습니다.

09httptest 패키지

net/http/httptest로 HTTP 핸들러를 단위 테스트합니다. 실제 서버 없이 요청/응답을 테스트합니다.

Go code

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

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

	healthHandler(rec, req)

	if rec.Code != 200 {
		t.Errorf("상태 코드: got %d, want 200", rec.Code)
	}

	var resp map[string]string
	json.Unmarshal(rec.Body.Bytes(), &resp)
	if resp["status"] != "ok" {
		t.Errorf("상태: got %s, want ok", resp["status"])
	}
}

// 테스트 서버로 통합 테스트
func TestWithServer(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(healthHandler))
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/health")
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		t.Errorf("상태: %d", resp.StatusCode)
	}
}

func main() {
	fmt.Println("httptest: 핸들러 단위 테스트 & 서버 통합 테스트")
}
알아두면 좋은 점

httptest.NewRecorder()는 핸들러 단위 테스트, httptest.NewServer()는 전체 HTTP 스택 통합 테스트에 사용합니다.

자주 하는 실수

httptest.NewServer를 사용 후 defer srv.Close()를 빼먹으면 테스트 후 서버가 종료되지 않습니다.

10목(Mock) 패턴

인터페이스 기반 목 객체로 외부 의존성을 대체합니다. 직접 구현하거나 mockgen을 사용합니다.

Go code

package main

import (
	"errors"
	"fmt"
	"testing"
)

type Mailer interface {
	Send(to, subject, body string) error
}

// 수동 목 구현
type MockMailer struct {
	SendFunc  func(to, subject, body string) error
	SendCalls []struct{ To, Subject, Body string }
}

func (m *MockMailer) Send(to, subject, body string) error {
	m.SendCalls = append(m.SendCalls, struct{ To, Subject, Body string }{to, subject, body})
	if m.SendFunc != nil { return m.SendFunc(to, subject, body) }
	return nil
}

type NotifyService struct{ mailer Mailer }

func (s *NotifyService) Welcome(email string) error {
	return s.mailer.Send(email, "환영합니다", "가입을 축하합니다!")
}

func TestWelcome(t *testing.T) {
	mock := &MockMailer{}
	svc := &NotifyService{mailer: mock}

	err := svc.Welcome("user@go.dev")
	if err != nil { t.Fatal(err) }

	if len(mock.SendCalls) != 1 { t.Fatal("Send 호출 1회 기대") }
	if mock.SendCalls[0].To != "user@go.dev" { t.Error("잘못된 수신자") }

	// 에러 시나리오
	mock.SendFunc = func(_, _, _ string) error { return errors.New("전송 실패") }
	err = svc.Welcome("fail@go.dev")
	if err == nil { t.Error("에러 기대") }
}

func main() {
	fmt.Println("인터페이스 기반 목 테스트 패턴")
}
알아두면 좋은 점

목 구조체에 호출 기록을 저장하면 "올바른 인자로 호출되었는가"를 검증할 수 있습니다.

자주 하는 실수

구체적 타입에 의존하면 목 교체가 불가능합니다. 항상 인터페이스에 의존하여 테스트 가능하게 만드세요.

11인터페이스 기반 테스트

인터페이스를 이용하여 구현체를 교체 가능하게 만들고, 테스트에서 스텁/목을 주입합니다.

Go code

package main

import (
	"context"
	"fmt"
	"testing"
	"time"
)

// 테스트 가능한 시간 인터페이스
type Clock interface {
	Now() time.Time
}

type RealClock struct{}
func (c RealClock) Now() time.Time { return time.Now() }

type FakeClock struct{ Fixed time.Time }
func (c FakeClock) Now() time.Time { return c.Fixed }

// 테스트 가능한 서비스
type TokenService struct{ clock Clock }

func (s *TokenService) Generate() string {
	t := s.clock.Now()
	return fmt.Sprintf("token_%d", t.Unix())
}

func (s *TokenService) IsExpired(token string, maxAge time.Duration) bool {
	// 토큰 생성 시각과 현재 시각 비교
	return false // 간단한 예시
}

func TestTokenGeneration(t *testing.T) {
	fixed := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
	svc := &TokenService{clock: FakeClock{Fixed: fixed}}

	token := svc.Generate()
	expected := fmt.Sprintf("token_%d", fixed.Unix())
	if token != expected {
		t.Errorf("got %s, want %s", token, expected)
	}
}

func main() {
	_ = context.Background()
	fmt.Println("인터페이스로 시간, 랜덤, IO를 테스트 가능하게")
}
알아두면 좋은 점

시간, 랜덤, 파일 시스템 등 비결정적 요소를 인터페이스로 추상화하면 테스트에서 예측 가능한 값을 주입할 수 있습니다.

자주 하는 실수

time.Now()를 직접 호출하면 테스트가 시간에 의존하여 불안정해집니다. Clock 인터페이스를 주입하세요.

12통합 테스트

빌드 태그와 환경 변수로 통합 테스트를 분리합니다. 실제 DB나 외부 서비스와 연동합니다.

Go code

package main

import (
	"fmt"
	"os"
	"testing"
)

// 통합 테스트 스킵 헬퍼
func skipIfShort(t *testing.T) {
	t.Helper()
	if testing.Short() {
		t.Skip("통합 테스트: -short 모드에서 건너뜀")
	}
}

func skipIfNoEnv(t *testing.T, key string) {
	t.Helper()
	if os.Getenv(key) == "" {
		t.Skipf("통합 테스트: %s 환경 변수 필요", key)
	}
}

func TestDatabaseIntegration(t *testing.T) {
	skipIfShort(t)
	skipIfNoEnv(t, "TEST_DB_URL")

	// dsn := os.Getenv("TEST_DB_URL")
	// db, err := sql.Open("postgres", dsn)
	// ...실제 DB 테스트

	t.Run("CreateUser", func(t *testing.T) {
		// DB에 사용자 생성 후 조회
	})

	t.Run("Transaction", func(t *testing.T) {
		// 트랜잭션 테스트
	})
}

// 파일 상단에 빌드 태그 사용 (Go 1.17+):
// //go:build integration

func main() {
	fmt.Println("통합 테스트 실행:")
	fmt.Println("  go test -v                    # 단위만")
	fmt.Println("  go test -v -short             # 빠른 테스트만")
	fmt.Println("  go test -v -tags integration  # 통합 포함")
}
알아두면 좋은 점

testing.Short()t.Skip으로 느린 통합 테스트를 빠른 CI에서 건너뛸 수 있습니다.

자주 하는 실수

통합 테스트에서 공유 DB를 사용하면 테스트 간 간섭이 발생합니다. 각 테스트마다 트랜잭션을 사용하고 롤백하세요.

13테스트 커버리지

go test -cover로 코드 커버리지를 측정합니다. HTML 보고서로 미커버 영역을 확인합니다.

Go code

package main

import (
	"fmt"
	"testing"
)

func Abs(n int) int {
	if n < 0 { return -n }
	return n
}

func Clamp(val, min, max int) int {
	if val < min { return min }
	if val > max { return max }
	return val
}

func TestAbs(t *testing.T) {
	tests := []struct{ input, want int }{
		{-5, 5}, {5, 5}, {0, 0},
	}
	for _, tt := range tests {
		if got := Abs(tt.input); got != tt.want {
			t.Errorf("Abs(%d) = %d, want %d", tt.input, got, tt.want)
		}
	}
}

func TestClamp(t *testing.T) {
	if Clamp(5, 0, 10) != 5 { t.Error("범위 내 실패") }
	if Clamp(-5, 0, 10) != 0 { t.Error("하한 실패") }
	if Clamp(15, 0, 10) != 10 { t.Error("상한 실패") }
}

func main() {
	fmt.Println("커버리지 명령어:")
	fmt.Println("  go test -cover              # 커버리지 %")
	fmt.Println("  go test -coverprofile=c.out  # 프로파일 생성")
	fmt.Println("  go tool cover -html=c.out    # HTML 보고서")
	fmt.Println("  go tool cover -func=c.out    # 함수별 커버리지")
}
알아두면 좋은 점

go tool cover -html=c.out은 커버되지 않은 코드를 빨간색으로 표시합니다. 에지 케이스를 찾는 데 유용합니다.

자주 하는 실수

커버리지 100%가 목표가 아닙니다. 핵심 비즈니스 로직과 에러 처리 경로의 커버리지가 더 중요합니다.

14퍼즈 테스트

Go 1.18의 퍼즈 테스트로 예상치 못한 입력에 대한 버그를 자동 발견합니다.

Go code

package main

import (
	"fmt"
	"testing"
	"unicode/utf8"
)

func Reverse(s string) string {
	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

func FuzzReverse(f *testing.F) {
	// 시드 코퍼스 (초기 입력)
	f.Add("hello")
	f.Add("세계")
	f.Add("")
	f.Add("🌍🎉")

	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
		doubleRev := Reverse(rev)

		// 불변식 검증
		if orig != doubleRev {
			t.Errorf("두 번 뒤집기 실패: %q → %q → %q",
				orig, rev, doubleRev)
		}
		if utf8.ValidString(orig) && !utf8.ValidString(rev) {
			t.Errorf("유효한 UTF-8이 뒤집기 후 무효: %q", orig)
		}
	})
}

func main() {
	fmt.Println("퍼즈 테스트:")
	fmt.Println("  go test -fuzz=FuzzReverse            # 퍼즈 실행")
	fmt.Println("  go test -fuzz=FuzzReverse -fuzztime=30s # 30초간 실행")
}
알아두면 좋은 점

퍼즈 테스트는 "입력-출력 쌍"보다 "불변식(invariant)"을 검증합니다. "두 번 적용하면 원래대로" 같은 속성이 적합합니다.

자주 하는 실수

퍼즈 함수 이름은 Fuzz로 시작해야 하며, *testing.F를 매개변수로 받아야 합니다. 일반 테스트와 시그니처가 다릅니다.

15골든 파일 테스트

기대 출력을 파일로 저장하고 비교하는 골든 파일 테스트. 복잡한 출력 검증에 적합합니다.

Go code

package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"testing"
)

var update = flag.Bool("update", false, "골든 파일 업데이트")

func golden(t *testing.T, name string, actual []byte) {
	t.Helper()
	path := filepath.Join("testdata", name+".golden")

	if *update {
		os.MkdirAll("testdata", 0755)
		os.WriteFile(path, actual, 0644)
		return
	}

	expected, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("골든 파일 없음: %s (--update로 생성)", path)
	}

	if string(actual) != string(expected) {
		t.Errorf("골든 파일 불일치:\n기대:\n%s\n실제:\n%s",
			string(expected), string(actual))
	}
}

func GenerateReport(name string) string {
	return fmt.Sprintf("=== 보고서 ===\n이름: %s\n상태: 정상\n", name)
}

func TestReport(t *testing.T) {
	actual := GenerateReport("테스트")
	golden(t, "report", []byte(actual))
}

func main() {
	fmt.Println("골든 파일 테스트:")
	fmt.Println("  go test -run TestReport -update  # 골든 파일 생성/업데이트")
	fmt.Println("  go test -run TestReport           # 비교 테스트")
}
알아두면 좋은 점

testdata/ 디렉토리는 Go 도구 체인이 특별 취급합니다. 빌드에서 제외되고 테스트 데이터 저장에 관례적으로 사용됩니다.

자주 하는 실수

골든 파일을 업데이트할 때 변경 내용을 검토하지 않으면 버그가 있는 출력이 기대값이 됩니다. 항상 diff를 확인하세요.

16testcontainers 패턴

Docker 컨테이너를 테스트에서 자동으로 시작/종료합니다. 실제 DB, Redis 등과 통합 테스트합니다.

Go code

package main

import (
	"context"
	"fmt"
	"testing"
)

// testcontainers-go 사용 예시
// import "github.com/testcontainers/testcontainers-go"

type TestDB struct {
	Host string
	Port int
	// container testcontainers.Container
}

func SetupPostgres(t *testing.T) *TestDB {
	t.Helper()

	// ctx := context.Background()
	// req := testcontainers.ContainerRequest{
	//     Image:        "postgres:16",
	//     ExposedPorts: []string{"5432/tcp"},
	//     Env: map[string]string{
	//         "POSTGRES_PASSWORD": "test",
	//         "POSTGRES_DB":       "testdb",
	//     },
	//     WaitingFor: wait.ForListeningPort("5432/tcp"),
	// }
	// container, _ := testcontainers.GenericContainer(ctx, req)
	// host, _ := container.Host(ctx)
	// port, _ := container.MappedPort(ctx, "5432")

	db := &TestDB{Host: "localhost", Port: 5432}

	t.Cleanup(func() {
		fmt.Println("컨테이너 정리")
		// container.Terminate(ctx)
	})

	return db
}

func TestWithPostgres(t *testing.T) {
	if testing.Short() { t.Skip("통합 테스트") }
	db := SetupPostgres(t)
	fmt.Printf("테스트 DB: %s:%d\n", db.Host, db.Port)
}

func main() {
	_ = context.Background()
	fmt.Println("testcontainers: Docker 기반 통합 테스트")
}
알아두면 좋은 점

t.Cleanup으로 컨테이너를 정리하면 테스트 실패 시에도 자동으로 정리됩니다.

자주 하는 실수

각 테스트마다 컨테이너를 시작하면 느립니다. TestMain에서 한 번 시작하고 여러 테스트에서 공유하세요.

17레이스 디텍터

-race 플래그로 동시성 버그를 감지합니다. 데이터 레이스를 컴파일/런타임에 발견합니다.

Go code

package main

import (
	"fmt"
	"sync"
	"testing"
)

// 레이스 컨디션이 있는 코드 (나쁜 예)
type UnsafeCounter struct {
	count int
}

func (c *UnsafeCounter) Inc() { c.count++ }
func (c *UnsafeCounter) Get() int { return c.count }

// 안전한 버전
type SafeCounter struct {
	mu    sync.Mutex
	count int
}

func (c *SafeCounter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.count++
}

func (c *SafeCounter) Get() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.count
}

func TestRace(t *testing.T) {
	c := &SafeCounter{}
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc()
		}()
	}
	wg.Wait()

	if c.Get() != 100 {
		t.Errorf("카운터: got %d, want 100", c.Get())
	}
}

func main() {
	fmt.Println("레이스 디텍터:")
	fmt.Println("  go test -race ./...   # 모든 테스트에 레이스 감지")
	fmt.Println("  go build -race        # 바이너리에 레이스 감지 포함")
	fmt.Println("  go run -race main.go  # 실행 시 감지")
}
알아두면 좋은 점

CI에서 go test -race를 항상 실행하세요. 레이스 디텍터는 10-20배 느려지므로 별도 파이프라인으로 분리할 수 있습니다.

자주 하는 실수

레이스 디텍터는 실제로 경합이 발생할 때만 감지합니다. 감지되지 않았다고 안전한 것은 아닙니다. 설계 수준에서 동시성을 고려하세요.

18병렬 테스트

t.Parallel()로 테스트를 병렬 실행하여 테스트 시간을 단축합니다.

Go code

package main

import (
	"fmt"
	"testing"
	"time"
)

func TestParallel(t *testing.T) {
	tests := []struct {
		name     string
		duration time.Duration
	}{
		{"빠른 테스트", 100 * time.Millisecond},
		{"중간 테스트", 200 * time.Millisecond},
		{"느린 테스트", 300 * time.Millisecond},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel() // 병렬 실행 선언

			// 독립적인 테스트 로직
			time.Sleep(tt.duration)
			// 순차: 600ms, 병렬: ~300ms
		})
	}
}

// 병렬 테스트에서 공유 리소스
func TestParallelWithSetup(t *testing.T) {
	// 셋업은 직렬로
	// db := setupTestDB(t)

	t.Run("group", func(t *testing.T) {
		t.Run("test1", func(t *testing.T) {
			t.Parallel()
			// db 사용 (읽기만)
		})
		t.Run("test2", func(t *testing.T) {
			t.Parallel()
			// db 사용 (읽기만)
		})
	})
	// 모든 병렬 서브테스트 완료 후 정리
}

func main() {
	fmt.Println("병렬 테스트: go test -v -parallel 4")
}
알아두면 좋은 점

-parallel N 플래그로 최대 병렬 수를 제어합니다. 기본값은 GOMAXPROCS입니다.

자주 하는 실수

병렬 테스트에서 공유 상태를 변경하면 레이스 컨디션이 발생합니다. 각 테스트는 독립적인 데이터를 사용하세요.

19CI/CD 테스트 전략

CI/CD 파이프라인에서의 Go 테스트 전략. 단계별 실행과 캐싱을 최적화합니다.

Go code

package main

import "fmt"

func main() {
	fmt.Println("=== CI/CD Go 테스트 전략 ===")
	fmt.Println()
	fmt.Println("1단계: 빠른 검증 (PR 체크)")
	fmt.Println("  go vet ./...")
	fmt.Println("  staticcheck ./...")
	fmt.Println("  go test -short -count=1 ./...")
	fmt.Println()
	fmt.Println("2단계: 전체 테스트")
	fmt.Println("  go test -race -count=1 ./...")
	fmt.Println("  go test -cover -coverprofile=coverage.out ./...")
	fmt.Println()
	fmt.Println("3단계: 통합 테스트")
	fmt.Println("  go test -v -tags=integration -count=1 ./...")
	fmt.Println()
	fmt.Println("4단계: 벤치마크 (주간)")
	fmt.Println("  go test -bench=. -benchmem -count=5 ./...")
	fmt.Println()
	fmt.Println("5단계: 퍼즈 테스트 (야간)")
	fmt.Println("  go test -fuzz=. -fuzztime=5m ./...")
	fmt.Println()
	fmt.Println("캐싱:")
	fmt.Println("  GOMODCACHE, GOCACHE 디렉토리를 CI 캐시에 저장")
	fmt.Println("  go test -count=1 로 캐시 무효화 (CI에서 권장)")
}
알아두면 좋은 점

go test -count=1은 테스트 캐시를 무효화합니다. CI에서는 항상 사용하여 실제 테스트를 실행하세요.

자주 하는 실수

CI에서 -race-cover를 동시에 사용하면 성능이 크게 저하됩니다. 별도 단계로 분리하세요.

정리하며

  • 테이블 + 서브테스트 조합으로 케이스마다 이름을 주면 실패 지점과 -run 필터가 명확해집니다
  • Go 1.22부터 루프 변수 복사는 불필요하지만, 하위 버전 대상 코드에서는 그대로 유지합니다
  • 병렬 서브테스트의 정리 코드는 defer 대신 t.Cleanup에 등록해 실행 순서를 맞춥니다
  • 동시성 코드는 -race를 CI 기본 옵션으로 두고, 입력 파싱 코드에는 퍼즈 테스트를 붙입니다

더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.