Go 테스트 코드 작성

Go 테스트 코드 작성: testing 패키지, 테이블 드리븐, Mock 패턴, 커버리지 측정

이 글에서는 Go의 testing 패키지를 내부 메서드 차이부터 실무 패턴까지 체계적으로 다룹니다. 단순히 if result != expected를 확인하는 수준을 넘어, 테이블 드리븐 테스트로 케이스를 체계화하고, 인터페이스 Mock으로 외부 의존성을 분리하며, httptest로 HTTP 핸들러를 격리 테스트하고, 벤치마크로 성능을 측정하고, 커버리지로 테스트 완성도를 수치화하는 — 실제 프로덕션 코드베이스에서 바로 적용할 수 있는 테스트 기법을 모두 담았습니다.


1. testing.T 핵심 메서드: Error vs Fatal, Log vs Logf

testing.T의 메서드 중 헷갈리는 것들의 차이를 먼저 정리합니다.

메서드동작테스트 중단 여부
t.Log(args...)테스트 실패 시에만 출력계속 실행
t.Logf(fmt, args...)포맷팅 버전계속 실행
t.Error(args...)실패 표시 + 메시지 출력계속 실행
t.Errorf(fmt, args...)포맷팅 버전계속 실행
t.Fatal(args...)실패 표시 + 즉시 중단즉시 중단
t.Fatalf(fmt, args...)포맷팅 버전즉시 중단
t.Skip(args...)테스트 건너뜀즉시 건너뜀
t.Helper()이 함수를 헬퍼로 표시 (에러 위치 개선)-
func TestUserService(t *testing.T) {
    db, err := connectTestDB()
    if err != nil {
        t.Fatal("DB 연결 실패:", err) // DB 없으면 더 진행 불가 → Fatal
    }
    defer db.Close()

    user, err := db.FindUser(1)
    if err != nil {
        t.Errorf("FindUser 실패: %v", err) // 실패 기록 후 계속
    }

    if user.Name == "" {
        t.Error("사용자 이름이 비어 있습니다") // 다음 검증도 계속
    }
    if user.Email == "" {
        t.Error("이메일이 비어 있습니다")
    }
}

🛠️ 언제 Fatal, 언제 Error? 이후 검증이 앞 결과에 의존한다면 Fatal을, 독립적인 여러 항목을 검증한다면 Error를 사용하세요. DB 연결 실패처럼 “이 이후로 아무것도 할 수 없는” 상황이 Fatal의 전형적인 사례입니다.


2. 테이블 드리븐 테스트 심화

2-1. 기본 구조

// calculator.go
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("0으로 나눌 수 없습니다")
    }
    return a / b, nil
}
// calculator_test.go
func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"양수 나눗셈", 10, 2, 5, false},
        {"음수 나눗셈", -6, 3, -2, false},
        {"소수 결과", 1, 3, 1.0 / 3.0, false},
        {"0으로 나누기", 5, 0, 0, true},
        {"0 나누기 0", 0, 0, 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)

            if (err != nil) != tt.wantErr {
                t.Fatalf("Divide(%v, %v) 에러 = %v, wantErr %v",
                    tt.a, tt.b, err, tt.wantErr)
            }
            if err == nil && got != tt.want {
                t.Errorf("Divide(%v, %v) = %v, want %v",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

2-2. 에러 타입까지 검증하는 테이블

type TestCase struct {
    name      string
    input     string
    wantValue int
    wantErr   error // 기대하는 에러 (nil이면 성공)
}

func TestParsePositive(t *testing.T) {
    tests := []TestCase{
        {"정상 숫자", "42", 42, nil},
        {"빈 문자열", "", 0, ErrEmptyInput},
        {"음수 입력", "-5", 0, ErrNegativeInput},
        {"숫자가 아닌 값", "abc", 0, ErrInvalidFormat},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParsePositive(tt.input)

            if !errors.Is(err, tt.wantErr) {
                t.Errorf("에러 = %v, 기대 = %v", err, tt.wantErr)
            }
            if got != tt.wantValue {
                t.Errorf("값 = %d, 기대 = %d", got, tt.wantValue)
            }
        })
    }
}

3. t.Parallel: 테스트 병렬 실행

독립적인 테스트는 병렬로 실행하면 전체 테스트 시간을 줄일 수 있습니다.

func TestUserCreation(t *testing.T) {
    t.Parallel() // 이 테스트를 다른 Parallel 테스트와 병렬 실행

    // ...
}

func TestUserDeletion(t *testing.T) {
    t.Parallel()

    // ...
}

테이블 드리븐 테스트에서 병렬 실행 시 클로저 변수 캡처 문제를 주의해야 합니다.

func TestProcessItems(t *testing.T) {
    tests := []struct {
        name  string
        input int
        want  int
    }{
        {"case1", 1, 2},
        {"case2", 2, 4},
        {"case3", 3, 6},
    }

    for _, tt := range tests {
        tt := tt // Go 1.22 미만에서 클로저 캡처 버그 방지 (복사 필수)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 서브테스트 병렬 실행
            got := doubleIt(tt.input)
            if got != tt.want {
                t.Errorf("doubleIt(%d) = %d, want %d", tt.input, got, tt.want)
            }
        })
    }
}

🛠️ Go 1.22부터 for 루프 변수가 반복마다 새로 생성되므로 tt := tt 복사가 불필요합니다. 하지만 Go 1.21 이하를 지원해야 한다면 여전히 필요합니다.


4. t.Cleanup과 TestMain

4-1. t.Cleanup: defer보다 안전한 정리

func TestWithDB(t *testing.T) {
    db := setupTestDB(t)
    t.Cleanup(func() {
        db.Close()          // 테스트 종료 시 자동 호출
        dropTestDB(db)      // 서브테스트 포함, 모두 끝난 후 실행
    })

    // ... 테스트 로직
}

// 헬퍼 함수에서도 사용 가능
func setupTestDB(t *testing.T) *sql.DB {
    t.Helper() // 에러 발생 시 이 함수 줄 번호 대신 호출자 줄 번호 표시
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatal("테스트 DB 연결 실패:", err)
    }
    t.Cleanup(func() { db.Close() }) // 헬퍼 내에서 등록
    return db
}

4-2. TestMain: 전역 테스트 설정

패키지 전체 테스트의 시작·종료 시점에 공통 작업이 필요하면 TestMain을 사용합니다.

// main_test.go
var testDB *sql.DB

func TestMain(m *testing.M) {
    // 전역 설정 — 패키지 내 모든 테스트 전 1회 실행
    var err error
    testDB, err = sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
    if err != nil {
        fmt.Fprintf(os.Stderr, "테스트 DB 연결 실패: %v\n", err)
        os.Exit(1)
    }

    if err := migrateTestDB(testDB); err != nil {
        fmt.Fprintf(os.Stderr, "마이그레이션 실패: %v\n", err)
        os.Exit(1)
    }

    // 테스트 실행
    exitCode := m.Run()

    // 전역 정리
    testDB.Close()
    os.Exit(exitCode)
}

5. 인터페이스 Mock으로 외부 의존성 분리

실무 테스트의 핵심은 외부 의존성(DB, HTTP API, 파일 시스템)을 Mock으로 교체하여 빠르고 격리된 테스트를 만드는 것입니다.

5-1. 인터페이스 정의

// internal/service/user.go

type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int64) error
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
    if id <= 0 {
        return nil, ErrValidationApp("id", "양수여야 합니다")
    }
    return s.repo.FindByID(ctx, id)
}

func (s *UserService) DeactivateUser(ctx context.Context, id int64) error {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return fmt.Errorf("사용자 조회 실패: %w", err)
    }
    user.Active = false
    return s.repo.Save(ctx, user)
}

5-2. Mock 구현

// internal/service/user_test.go

// MockUserRepository: UserRepository 인터페이스를 구현하는 Mock
type MockUserRepository struct {
    FindByIDFn func(ctx context.Context, id int64) (*User, error)
    SaveFn     func(ctx context.Context, user *User) error
    DeleteFn   func(ctx context.Context, id int64) error

    // 호출 추적
    FindByIDCalls []int64
    SaveCalls     []*User
}

func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    m.FindByIDCalls = append(m.FindByIDCalls, id)
    if m.FindByIDFn != nil {
        return m.FindByIDFn(ctx, id)
    }
    return nil, nil
}

func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
    m.SaveCalls = append(m.SaveCalls, user)
    if m.SaveFn != nil {
        return m.SaveFn(ctx, user)
    }
    return nil
}

func (m *MockUserRepository) Delete(ctx context.Context, id int64) error {
    if m.DeleteFn != nil {
        return m.DeleteFn(ctx, id)
    }
    return nil
}

5-3. Mock을 사용한 서비스 테스트

func TestUserService_GetUser(t *testing.T) {
    tests := []struct {
        name      string
        userID    int64
        mockSetup func(*MockUserRepository)
        wantUser  *User
        wantErr   bool
    }{
        {
            name:   "정상 조회",
            userID: 1,
            mockSetup: func(m *MockUserRepository) {
                m.FindByIDFn = func(_ context.Context, id int64) (*User, error) {
                    return &User{ID: 1, Name: "홍길동", Active: true}, nil
                }
            },
            wantUser: &User{ID: 1, Name: "홍길동", Active: true},
            wantErr:  false,
        },
        {
            name:   "존재하지 않는 사용자",
            userID: 999,
            mockSetup: func(m *MockUserRepository) {
                m.FindByIDFn = func(_ context.Context, id int64) (*User, error) {
                    return nil, ErrNotFound
                }
            },
            wantUser: nil,
            wantErr:  true,
        },
        {
            name:      "유효하지 않은 ID (0)",
            userID:    0,
            mockSetup: func(m *MockUserRepository) {}, // Mock 호출 안 됨
            wantErr:   true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockRepo := &MockUserRepository{}
            tt.mockSetup(mockRepo)

            svc := NewUserService(mockRepo)
            got, err := svc.GetUser(context.Background(), tt.userID)

            if (err != nil) != tt.wantErr {
                t.Fatalf("에러 = %v, wantErr = %v", err, tt.wantErr)
            }
            if !tt.wantErr && got.Name != tt.wantUser.Name {
                t.Errorf("이름 = %q, want %q", got.Name, tt.wantUser.Name)
            }
        })
    }
}

func TestUserService_DeactivateUser(t *testing.T) {
    mockRepo := &MockUserRepository{
        FindByIDFn: func(_ context.Context, id int64) (*User, error) {
            return &User{ID: id, Name: "홍길동", Active: true}, nil
        },
    }

    svc := NewUserService(mockRepo)
    err := svc.DeactivateUser(context.Background(), 1)

    if err != nil {
        t.Fatalf("예상치 못한 에러: %v", err)
    }
    // Save가 1번 호출되었고, Active가 false인지 확인
    if len(mockRepo.SaveCalls) != 1 {
        t.Fatalf("Save 호출 횟수 = %d, want 1", len(mockRepo.SaveCalls))
    }
    if mockRepo.SaveCalls[0].Active {
        t.Error("비활성화 후에도 Active가 true입니다")
    }
}

6. httptest로 HTTP 핸들러 테스트

net/http/httptest 패키지를 사용하면 실제 서버를 띄우지 않고 HTTP 핸들러를 테스트할 수 있습니다.

// internal/handler/user_test.go

func TestUserHandler_GetProfile(t *testing.T) {
    tests := []struct {
        name       string
        userID     string
        mockSetup  func(*MockUserService)
        wantStatus int
        wantBody   string
    }{
        {
            name:   "정상 조회",
            userID: "1",
            mockSetup: func(m *MockUserService) {
                m.GetUserFn = func(_ context.Context, id int64) (*User, error) {
                    return &User{ID: 1, Name: "홍길동"}, nil
                }
            },
            wantStatus: http.StatusOK,
            wantBody:   `"name":"홍길동"`,
        },
        {
            name:   "잘못된 ID",
            userID: "abc",
            mockSetup:  func(m *MockUserService) {},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:   "존재하지 않는 사용자",
            userID: "999",
            mockSetup: func(m *MockUserService) {
                m.GetUserFn = func(_ context.Context, id int64) (*User, error) {
                    return nil, &AppError{HTTPStatus: 404, Code: "NOT_FOUND", Message: "사용자 없음"}
                }
            },
            wantStatus: http.StatusNotFound,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockSvc := &MockUserService{}
            tt.mockSetup(mockSvc)

            handler := NewUserHandler(mockSvc)

            // httptest.NewRecorder: ResponseWriter를 흉내내는 레코더
            w := httptest.NewRecorder()
            // httptest.NewRequest: 실제 HTTP 요청 없이 Request 생성
            r := httptest.NewRequest(http.MethodGet, "/users/"+tt.userID, nil)
            // Go 1.22 PathValue 사용 시 mux를 통해 라우팅
            mux := http.NewServeMux()
            mux.HandleFunc("GET /users/{id}", handler.GetProfile)
            mux.ServeHTTP(w, r)

            resp := w.Result()
            if resp.StatusCode != tt.wantStatus {
                t.Errorf("상태 코드 = %d, want %d", resp.StatusCode, tt.wantStatus)
            }

            if tt.wantBody != "" {
                body, _ := io.ReadAll(resp.Body)
                if !strings.Contains(string(body), tt.wantBody) {
                    t.Errorf("응답 본문에 %q 없음\n본문: %s", tt.wantBody, body)
                }
            }
        })
    }
}

7. 벤치마크 테스트

Benchmark로 시작하는 함수는 성능 측정에 사용됩니다.

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ { // b.N은 벤치마크 프레임워크가 자동 결정
        Add(1, 2)
    }
}

// 비교 벤치마크: 두 구현의 성능 비교
func BenchmarkMarshalJSON(b *testing.B) {
    user := User{ID: 1, Name: "홍길동", Email: "hong@example.com"}
    b.ResetTimer() // 셋업 시간 제외

    for i := 0; i < b.N; i++ {
        _, _ = json.Marshal(user)
    }
}

func BenchmarkEncodeJSON(b *testing.B) {
    user := User{ID: 1, Name: "홍길동", Email: "hong@example.com"}
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        var buf bytes.Buffer
        _ = json.NewEncoder(&buf).Encode(user)
    }
}
# 벤치마크 실행
go test -bench=. -benchmem

# 특정 벤치마크만
go test -bench=BenchmarkMarshal -benchtime=5s

# 결과 예시:
# BenchmarkMarshalJSON-8    3000000    450 ns/op    64 B/op    1 allocs/op

-benchmem 옵션은 메모리 할당 횟수(allocs/op)와 할당량(B/op)을 함께 출력합니다.


8. 커버리지 측정

# 커버리지 비율 출력
go test -cover ./...

# HTML 리포트 생성
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

# 패키지별 커버리지
go test -coverprofile=coverage.out -covermode=count ./...
go tool cover -func=coverage.out

🛠️ 커버리지 100%가 목표가 아닙니다. 중요한 비즈니스 로직과 에러 처리 경로에 집중하세요. main 함수, 로깅 코드, 단순 getter/setter까지 테스트하는 것은 비용 대비 효과가 낮습니다. 일반적으로 70~80%를 실용적인 목표로 삼습니다.


9. 예제 테스트 (Example)

Example로 시작하는 함수는 문서화와 테스트를 동시에 합니다. // Output: 주석과 실제 출력이 일치해야 테스트가 통과합니다.

// 함수명: ExampleFunctionName
// 메서드명: ExampleType_MethodName
func ExampleAdd() {
    result := Add(3, 4)
    fmt.Println(result)
    // Output:
    // 7
}

func ExampleDivide() {
    result, err := Divide(10, 3)
    if err != nil {
        fmt.Println("에러:", err)
        return
    }
    fmt.Printf("%.4f\n", result)
    // Output:
    // 3.3333
}

예제 함수는 go doc 명령어나 pkg.go.dev에서 자동으로 문서에 포함됩니다.


10. 테스트 헬퍼 패턴

테스트에서 반복되는 검증 로직은 헬퍼 함수로 추출합니다.

// testutil/assert.go (테스트 전용 유틸리티)

func AssertEqual(t *testing.T, got, want any) {
    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 AssertErrorIs(t *testing.T, err, target error) {
    t.Helper()
    if !errors.Is(err, target) {
        t.Errorf("에러 = %v, 기대 = %v", err, target)
    }
}

11. 테스트 안티패턴

안티패턴 1: 전역 상태 공유

// ❌ 테스트 간 상태 공유 — 실행 순서에 따라 결과 달라짐
var sharedCounter int

func TestIncrease(t *testing.T) {
    sharedCounter++
    // ...
}

// ✅ 각 테스트에서 독립된 상태 생성
func TestIncrease(t *testing.T) {
    counter := 0
    counter++
    // ...
}

안티패턴 2: time.Sleep으로 비동기 대기

// ❌ 비결정적 — 느린 환경에서 실패
go processAsync()
time.Sleep(100 * time.Millisecond) // "충분히 기다리겠지?"
assertDone()

// ✅ 채널이나 sync.WaitGroup으로 명시적 동기화
done := make(chan struct{})
go func() {
    processAsync()
    close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
    t.Fatal("타임아웃: processAsync가 완료되지 않음")
}

안티패턴 3: 테스트에서 외부 서비스 직접 호출

// ❌ 외부 API 호출 — 느리고 네트워크 의존적
func TestGetWeather(t *testing.T) {
    result, err := http.Get("https://api.weather.com/v1/current")
    // ...
}

// ✅ httptest.NewServer로 Mock 서버 사용
func TestGetWeather(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprint(w, `{"temp": 25, "condition": "sunny"}`)
    }))
    defer server.Close()

    client := NewWeatherClient(server.URL)
    result, err := client.GetCurrent()
    // ...
}

핵심 요약

다음 글에서는 Go 패키지와 모듈 구조 설계를 다룹니다. 프로젝트 레이아웃, 패키지 분리 전략, 의존성 관리, 내부 패키지까지 살펴보겠습니다.