Skip to content
Published on

Go 테스팅 완전 가이드: 단위 테스트, Mock, Stub 완벽 정복

Authors

소개

소프트웨어 품질은 테스트로 증명됩니다. Go는 언어 자체에 강력한 테스팅 도구를 내장하고 있으며, testing 패키지와 go test 명령어만으로도 프로덕션 수준의 테스트를 작성할 수 있습니다.

이 가이드에서는 기본 단위 테스트부터 Mock/Stub을 활용한 의존성 분리, 통합 테스트까지 실전에서 바로 사용 가능한 패턴을 다룹니다.


1. Go 테스팅 기본

1.1 testing 패키지

Go의 테스트는 _test.go 파일에 작성하며, 테스트 함수는 Test 접두사로 시작합니다.

// calculator.go
package calculator

func Add(a, b int) int    { return a + b }
func Sub(a, b int) int    { return a - b }
func Mul(a, b int) int    { return a * b }
func Div(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}
// calculator_test.go
package calculator

import (
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

// t.Fatal: 이후 테스트 코드 실행 중단
func TestDiv(t *testing.T) {
    result, err := Div(10, 2)
    if err != nil {
        t.Fatalf("예상치 못한 에러: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Div(10, 2) = %f; want 5.0", result)
    }

    // 0으로 나누기 에러 테스트
    _, err = Div(10, 0)
    if err == nil {
        t.Error("0으로 나누기 시 에러를 반환해야 합니다")
    }
}

// t.Helper(): 헬퍼 함수에서 호출 시 오류 위치가 헬퍼 내부가 아닌 호출 지점으로 표시
func assertEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMul(t *testing.T) {
    assertEqual(t, Mul(3, 4), 12)
    assertEqual(t, Mul(0, 100), 0)
    assertEqual(t, Mul(-2, 5), -10)
}

1.2 테스트 실행

# 현재 패키지 테스트
go test

# 모든 패키지 테스트
go test ./...

# 상세 출력
go test -v

# 특정 테스트 함수만 실행 (정규식)
go test -run TestAdd
go test -run TestDiv

# 테스트 커버리지
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out    # 브라우저로 커버리지 확인
go tool cover -func=coverage.out    # 함수별 커버리지

# 타임아웃 설정
go test -timeout 30s

# 레이스 컨디션 감지
go test -race

# 캐시 무시
go test -count=1 ./...

1.3 TestMain

package mypackage_test

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

var testDB *Database

// TestMain: 테스트 실행 전/후 설정 및 해제
func TestMain(m *testing.M) {
    // 설정 (setup)
    fmt.Println("테스트 환경 설정 시작")
    testDB = setupTestDatabase()

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

    // 해제 (teardown)
    testDB.Close()
    fmt.Println("테스트 환경 해제 완료")

    os.Exit(code)
}

2. 테이블 주도 테스트 (Table-Driven Tests)

Go에서 가장 관용적인 테스트 패턴입니다.

package calculator_test

import (
    "fmt"
    "testing"

    "github.com/example/calculator"
)

func TestAdd_TableDriven(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"양수+양수", 2, 3, 5},
        {"양수+음수", 5, -3, 2},
        {"음수+음수", -4, -6, -10},
        {"제로케이스", 0, 0, 0},
        {"대수", 1000000, 2000000, 3000000},
    }

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

// 에러 케이스를 포함한 테이블 테스트
func TestDiv_TableDriven(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"정상 나누기", 10, 2, 5.0, false},
        {"소수 결과", 7, 2, 3.5, false},
        {"0으로 나누기", 10, 0, 0, true},
        {"음수", -10, 2, -5.0, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := calculator.Div(tt.a, tt.b)
            if tt.expectErr {
                if err == nil {
                    t.Error("에러를 기대했지만 nil이 반환됨")
                }
                return
            }
            if err != nil {
                t.Fatalf("예상치 못한 에러: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Div(%.1f, %.1f) = %.1f; want %.1f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

// 서브테스트 setup/teardown 패턴
func TestUserService_TableDriven(t *testing.T) {
    tests := []struct {
        name    string
        userID  int
        wantErr bool
    }{
        {"유효한 유저", 1, false},
        {"존재하지 않는 유저", 999, true},
        {"음수 ID", -1, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // 서브테스트별 setup
            svc := NewTestUserService()
            defer svc.Cleanup()

            user, err := svc.GetUser(tt.userID)
            if (err != nil) != tt.wantErr {
                t.Errorf("GetUser(%d) error = %v, wantErr %v",
                    tt.userID, err, tt.wantErr)
                return
            }
            if !tt.wantErr {
                fmt.Printf("유저: %+v\n", user)
            }
        })
    }
}

3. testify 라이브러리

testify는 Go에서 가장 많이 사용되는 테스트 라이브러리입니다.

go get github.com/stretchr/testify

3.1 assert vs require

package mypackage_test

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

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)

    // assert: 실패해도 테스트 계속 실행
    assert.Equal(t, 5, result, "2+3은 5여야 합니다")
    assert.NotEqual(t, 6, result)
    assert.True(t, result > 0)
    assert.False(t, result < 0)

    // require: 실패하면 테스트 즉시 중단
    require.NotNil(t, result)
    require.Equal(t, 5, result)
}

func TestCollections(t *testing.T) {
    nums := []int{1, 2, 3, 4, 5}

    assert.Contains(t, nums, 3)
    assert.NotContains(t, nums, 10)
    assert.Len(t, nums, 5)
    assert.Empty(t, []int{})
    assert.NotEmpty(t, nums)

    m := map[string]int{"a": 1, "b": 2}
    assert.Equal(t, 1, m["a"])
}

func TestErrors(t *testing.T) {
    _, err := Div(10, 0)
    assert.Error(t, err)
    assert.EqualError(t, err, "division by zero")

    result, err := Div(10, 2)
    assert.NoError(t, err)
    assert.Equal(t, 5.0, result)
}

func TestPanic(t *testing.T) {
    assert.Panics(t, func() {
        panic("test panic")
    })
    assert.NotPanics(t, func() {
        _ = Add(1, 2)
    })
}

3.2 assert.JSONEq와 HTTP 어서션

package mypackage_test

import (
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestJSONResponse(t *testing.T) {
    handler := func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"name":"Alice","age":30}`))
    }

    req := httptest.NewRequest(http.MethodGet, "/user", nil)
    rec := httptest.NewRecorder()
    handler(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    // JSON 내용 비교 (키 순서 무관)
    expected := `{"age": 30, "name": "Alice"}`
    assert.JSONEq(t, expected, rec.Body.String())
}

3.3 testify/suite

package mypackage_test

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

type UserServiceSuite struct {
    suite.Suite
    service *UserService
    db      *TestDB
}

// 스위트 시작 전 한 번 실행
func (s *UserServiceSuite) SetupSuite() {
    s.db = NewTestDB()
}

// 각 테스트 전 실행
func (s *UserServiceSuite) SetupTest() {
    s.service = NewUserService(s.db)
    s.db.Seed()
}

// 각 테스트 후 실행
func (s *UserServiceSuite) TearDownTest() {
    s.db.Clean()
}

// 스위트 종료 후 한 번 실행
func (s *UserServiceSuite) TearDownSuite() {
    s.db.Close()
}

func (s *UserServiceSuite) TestGetUser() {
    user, err := s.service.GetUser(1)
    s.NoError(err)
    s.Equal("Alice", user.Name)
}

func (s *UserServiceSuite) TestCreateUser() {
    user, err := s.service.CreateUser("Bob", "bob@example.com")
    s.NoError(err)
    s.NotZero(user.ID)
    s.Equal("Bob", user.Name)
}

func (s *UserServiceSuite) TestDeleteUser_NotFound() {
    err := s.service.DeleteUser(9999)
    s.Error(err)
    assert.Contains(s.T(), err.Error(), "not found")
}

// Go 테스트 러너에 스위트 등록
func TestUserServiceSuite(t *testing.T) {
    suite.Run(t, new(UserServiceSuite))
}

4. 인터페이스를 활용한 Mock

4.1 인터페이스 기반 의존성 주입

// user_repository.go
package user

import "context"

type User struct {
    ID    int
    Name  string
    Email string
}

// 인터페이스 정의 (의존성 주입의 핵심)
type Repository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context) ([]*User, error)
}

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

// 서비스는 인터페이스에 의존 (구체적 구현에 의존하지 않음)
type Service struct {
    repo  Repository
    email EmailSender
}

func NewService(repo Repository, email EmailSender) *Service {
    return &Service{repo: repo, email: email}
}

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
    return s.repo.FindByID(ctx, id)
}

func (s *Service) CreateUser(ctx context.Context, name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(ctx, user); err != nil {
        return nil, fmt.Errorf("saving user: %w", err)
    }
    // 이메일 발송 (실패해도 유저 생성은 성공)
    _ = s.email.Send(email, "환영합니다", "회원가입을 축하합니다!")
    return user, nil
}

4.2 수동 Mock 구현

// mock_repository.go (테스트 파일에서 직접 정의 가능)
package user_test

import (
    "context"
    "fmt"
    "github.com/example/user"
)

type MockRepository struct {
    users   map[int]*user.User
    saveErr error
    findErr error
}

func NewMockRepository() *MockRepository {
    return &MockRepository{users: make(map[int]*user.User)}
}

func (m *MockRepository) FindByID(ctx context.Context, id int) (*user.User, error) {
    if m.findErr != nil {
        return nil, m.findErr
    }
    u, ok := m.users[id]
    if !ok {
        return nil, fmt.Errorf("user %d not found", id)
    }
    return u, nil
}

func (m *MockRepository) Save(ctx context.Context, u *user.User) error {
    if m.saveErr != nil {
        return m.saveErr
    }
    if u.ID == 0 {
        u.ID = len(m.users) + 1
    }
    m.users[u.ID] = u
    return nil
}

func (m *MockRepository) Delete(ctx context.Context, id int) error {
    delete(m.users, id)
    return nil
}

func (m *MockRepository) List(ctx context.Context) ([]*user.User, error) {
    users := make([]*user.User, 0, len(m.users))
    for _, u := range m.users {
        users = append(users, u)
    }
    return users, nil
}

type MockEmailSender struct {
    SentEmails []struct{ To, Subject, Body string }
    SendErr    error
}

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

5. gomock 사용법

5.1 gomock 설치와 코드 생성

go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/gomock

인터페이스 파일에 go:generate 지시어 추가:

// repository.go
package user

//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks
type Repository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context) ([]*User, error)
}
# Mock 생성
go generate ./...

5.2 gomock 테스트 작성

package user_test

import (
    "context"
    "testing"

    "github.com/example/user"
    "github.com/example/user/mocks"
    "go.uber.org/mock/gomock"
    "github.com/stretchr/testify/assert"
)

func TestGetUser_WithGomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    mockEmail := mocks.NewMockEmailSender(ctrl)

    expectedUser := &user.User{ID: 1, Name: "Alice", Email: "alice@example.com"}

    // EXPECT: 메서드 호출 기대 설정
    mockRepo.EXPECT().
        FindByID(gomock.Any(), 1).
        Return(expectedUser, nil).
        Times(1)

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expectedUser, got)
}

func TestCreateUser_WithGomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    mockEmail := mocks.NewMockEmailSender(ctrl)

    // Save가 정확히 1번 호출되어야 함
    mockRepo.EXPECT().
        Save(gomock.Any(), gomock.Any()).
        DoAndReturn(func(ctx context.Context, u *user.User) error {
            u.ID = 42 // ID 할당 시뮬레이션
            return nil
        }).
        Times(1)

    // Send는 0번 이상 호출될 수 있음
    mockEmail.EXPECT().
        Send(gomock.Any(), gomock.Any(), gomock.Any()).
        Return(nil).
        AnyTimes()

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")

    assert.NoError(t, err)
    assert.Equal(t, 42, got.ID)
    assert.Equal(t, "Bob", got.Name)
}

// 특정 인수 매처 사용
func TestFindByID_SpecificArgs(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)

    // 정확한 인수 매칭
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Eq(5)).
        Return(nil, fmt.Errorf("not found")).
        Times(1)

    // 커스텀 매처
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Not(gomock.Eq(0))).
        Return(&user.User{ID: 10}, nil).
        AnyTimes()
}

6. testify/mock 사용법

6.1 testify/mock으로 Mock 구현

package user_test

import (
    "context"
    "testing"

    "github.com/example/user"
    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/assert"
)

// testify/mock을 임베딩한 Mock 구조체
type MockUserRepo struct {
    mock.Mock
}

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

func (m *MockUserRepo) Save(ctx context.Context, u *user.User) error {
    args := m.Called(ctx, u)
    return args.Error(0)
}

func (m *MockUserRepo) Delete(ctx context.Context, id int) error {
    args := m.Called(ctx, id)
    return args.Error(0)
}

func (m *MockUserRepo) List(ctx context.Context) ([]*user.User, error) {
    args := m.Called(ctx)
    return args.Get(0).([]*user.User), args.Error(1)
}

func TestGetUser_Testify(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockEmail := new(MockEmailSender)

    expectedUser := &user.User{ID: 1, Name: "Alice"}

    // On으로 메서드 동작 설정
    mockRepo.On("FindByID", mock.Anything, 1).
        Return(expectedUser, nil)

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expectedUser, got)

    // 모든 기대가 충족되었는지 확인
    mockRepo.AssertExpectations(t)
}

func TestCreateUser_Testify(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockEmail := new(MockEmailSender)

    // mock.MatchedBy로 커스텀 매처 사용
    mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(u *user.User) bool {
        return u.Name == "Bob"
    })).Return(nil)

    mockEmail.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil)

    svc := user.NewService(mockRepo, mockEmail)
    _, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")

    assert.NoError(t, err)
    mockRepo.AssertNumberOfCalls(t, "Save", 1)
    mockEmail.AssertNumberOfCalls(t, "Send", 1)
}

7. HTTP Mock 서버 (httptest)

7.1 httptest.NewRecorder

package handler_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestUserHandler_GetAll(t *testing.T) {
    // 핸들러 생성
    store := NewInMemoryStore()
    store.Add(&User{ID: 1, Name: "Alice"})
    store.Add(&User{ID: 2, Name: "Bob"})

    handler := NewUserHandler(store)

    // 요청 생성
    req := httptest.NewRequest(http.MethodGet, "/users", nil)
    rec := httptest.NewRecorder()

    // 핸들러 직접 호출
    handler.ServeHTTP(rec, req)

    // 응답 검증
    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    var users []User
    err := json.NewDecoder(rec.Body).Decode(&users)
    assert.NoError(t, err)
    assert.Len(t, users, 2)
}

func TestUserHandler_Create(t *testing.T) {
    store := NewInMemoryStore()
    handler := NewUserHandler(store)

    body := map[string]string{
        "name":  "Charlie",
        "email": "charlie@example.com",
    }
    jsonBody, _ := json.Marshal(body)

    req := httptest.NewRequest(http.MethodPost, "/users",
        bytes.NewBuffer(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusCreated, rec.Code)

    var created User
    json.NewDecoder(rec.Body).Decode(&created)
    assert.Equal(t, "Charlie", created.Name)
    assert.NotZero(t, created.ID)
}

7.2 httptest.NewServer (외부 API 모킹)

package client_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestHTTPClient_GetUser(t *testing.T) {
    // 외부 서버를 모킹하는 테스트 서버
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 요청 검증
        assert.Equal(t, "/api/users/1", r.URL.Path)
        assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

        // 응답 반환
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "id":    1,
            "name":  "Alice",
            "email": "alice@example.com",
        })
    }))
    defer ts.Close()

    // 테스트 서버 URL로 클라이언트 생성
    client := NewUserClient(ts.URL, "test-token")

    user, err := client.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}

// 에러 시나리오 테스트
func TestHTTPClient_ServerError(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
    }))
    defer ts.Close()

    client := NewUserClient(ts.URL, "test-token")
    _, err := client.GetUser(1)
    assert.Error(t, err)
}

// 라우터 설정이 있는 테스트 서버
func TestHTTPClient_MultipleEndpoints(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode([]User{{ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}})
    })
    mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    ts := httptest.NewServer(mux)
    defer ts.Close()

    client := NewUserClient(ts.URL, "token")

    users, err := client.ListUsers()
    assert.NoError(t, err)
    assert.Len(t, users, 2)
}

8. Stub 패턴

8.1 인터페이스 기반 Stub

// 시간 Stub: time.Now()를 교체 가능하게 만들기
type Clock interface {
    Now() time.Time
}

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

type FakeClock struct {
    fakeTime time.Time
}
func (f FakeClock) Now() time.Time { return f.fakeTime }

// 파일시스템 Stub
type FileSystem interface {
    ReadFile(name string) ([]byte, error)
    WriteFile(name string, data []byte, perm os.FileMode) error
    Exists(name string) bool
}

type OSFileSystem struct{}
func (f OSFileSystem) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
func (f OSFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
    return os.WriteFile(name, data, perm)
}
func (f OSFileSystem) Exists(name string) bool {
    _, err := os.Stat(name)
    return !os.IsNotExist(err)
}

// 인메모리 파일시스템 Stub
type MemoryFileSystem struct {
    files map[string][]byte
}

func NewMemoryFileSystem() *MemoryFileSystem {
    return &MemoryFileSystem{files: make(map[string][]byte)}
}

func (m *MemoryFileSystem) ReadFile(name string) ([]byte, error) {
    data, ok := m.files[name]
    if !ok {
        return nil, fmt.Errorf("file not found: %s", name)
    }
    return data, nil
}

func (m *MemoryFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
    m.files[name] = data
    return nil
}

func (m *MemoryFileSystem) Exists(name string) bool {
    _, ok := m.files[name]
    return ok
}

// Stub을 활용한 서비스 테스트
type ConfigService struct {
    fs    FileSystem
    clock Clock
}

func (s *ConfigService) LoadConfig(path string) (*Config, error) {
    data, err := s.fs.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    cfg.LoadedAt = s.clock.Now()
    return &cfg, nil
}

8.2 데이터베이스 Stub (sqlmock)

package repository_test

import (
    "testing"
    "regexp"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/stretchr/testify/assert"
)

func TestUserRepository_FindByID(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("sqlmock 생성 실패: %v", err)
    }
    defer db.Close()

    // 쿼리 기대 설정
    rows := sqlmock.NewRows([]string{"id", "name", "email"}).
        AddRow(1, "Alice", "alice@example.com")

    mock.ExpectQuery(regexp.QuoteMeta("SELECT id, name, email FROM users WHERE id = ?")).
        WithArgs(1).
        WillReturnRows(rows)

    repo := NewSQLUserRepository(db)
    user, err := repo.FindByID(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)

    // 모든 기대가 충족되었는지 확인
    assert.NoError(t, mock.ExpectationsWereMet())
}

func TestUserRepository_Save(t *testing.T) {
    db, mock, _ := sqlmock.New()
    defer db.Close()

    mock.ExpectExec(regexp.QuoteMeta(
        "INSERT INTO users (name, email) VALUES (?, ?)")).
        WithArgs("Bob", "bob@example.com").
        WillReturnResult(sqlmock.NewResult(42, 1))

    repo := NewSQLUserRepository(db)
    user := &User{Name: "Bob", Email: "bob@example.com"}
    err := repo.Save(context.Background(), user)

    assert.NoError(t, err)
    assert.Equal(t, 42, user.ID)
    assert.NoError(t, mock.ExpectationsWereMet())
}

9. 모의 서버 (Mock Server) 만들기

// mockserver/server.go
package mockserver

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

type RequestRecord struct {
    Method  string
    Path    string
    Headers http.Header
    Body    []byte
}

type MockResponse struct {
    Status  int
    Headers map[string]string
    Body    interface{}
}

type Route struct {
    Method   string
    Path     string
    Response MockResponse
}

type MockServer struct {
    t        *testing.T
    server   *httptest.Server
    routes   []Route
    requests []RequestRecord
    mu       sync.Mutex
}

func New(t *testing.T) *MockServer {
    ms := &MockServer{t: t}

    mux := http.NewServeMux()
    mux.HandleFunc("/", ms.handleRequest)

    ms.server = httptest.NewServer(mux)
    t.Cleanup(ms.server.Close)

    return ms
}

func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    // 요청 기록
    body, _ := io.ReadAll(r.Body)
    ms.requests = append(ms.requests, RequestRecord{
        Method:  r.Method,
        Path:    r.URL.Path,
        Headers: r.Header.Clone(),
        Body:    body,
    })

    // 라우트 매칭
    for _, route := range ms.routes {
        if route.Method == r.Method && route.Path == r.URL.Path {
            for k, v := range route.Response.Headers {
                w.Header().Set(k, v)
            }
            if _, ok := route.Response.Headers["Content-Type"]; !ok {
                w.Header().Set("Content-Type", "application/json")
            }
            w.WriteHeader(route.Response.Status)
            if route.Response.Body != nil {
                json.NewEncoder(w).Encode(route.Response.Body)
            }
            return
        }
    }

    // 매칭되는 라우트 없음
    ms.t.Errorf("예상치 못한 요청: %s %s", r.Method, r.URL.Path)
    http.Error(w, "Not Found", http.StatusNotFound)
}

func (ms *MockServer) AddRoute(method, path string, resp MockResponse) *MockServer {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    ms.routes = append(ms.routes, Route{Method: method, Path: path, Response: resp})
    return ms
}

func (ms *MockServer) URL() string {
    return ms.server.URL
}

func (ms *MockServer) Requests() []RequestRecord {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    return append([]RequestRecord{}, ms.requests...)
}

func (ms *MockServer) AssertRequestCount(t *testing.T, method, path string, count int) {
    t.Helper()
    actual := 0
    for _, r := range ms.Requests() {
        if r.Method == method && r.Path == path {
            actual++
        }
    }
    if actual != count {
        t.Errorf("%s %s: 요청 횟수 = %d, want %d", method, path, actual, count)
    }
}

// 사용 예시
func TestWithMockServer(t *testing.T) {
    ms := New(t)

    ms.AddRoute("GET", "/api/users", MockResponse{
        Status: 200,
        Body:   []map[string]interface{}{{"id": 1, "name": "Alice"}},
    })
    ms.AddRoute("POST", "/api/users", MockResponse{
        Status: 201,
        Body:   map[string]interface{}{"id": 2, "name": "Bob"},
    })

    client := NewAPIClient(ms.URL())

    users, err := client.GetUsers()
    assert.NoError(t, err)
    assert.Len(t, users, 1)

    ms.AssertRequestCount(t, "GET", "/api/users", 1)
}

10. Benchmark 테스트

package calculator_test

import "testing"

// Benchmark: go test -bench=. 으로 실행
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkFibonacci(b *testing.B) {
    sizes := []int{10, 20, 30}
    for _, n := range sizes {
        n := n
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Fibonacci(n)
            }
        })
    }
}

// 메모리 할당 추적
func BenchmarkStringConcat(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < 100; j++ {
            s += "x"
        }
        _ = s
    }
}

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

11. 퀴즈

퀴즈 1: assert vs require 차이

assert.Equalrequire.Equal의 차이는 무엇인가요? 언제 어떤 것을 사용해야 하나요?

정답:

  • assert.Equal: 실패해도 테스트를 계속 실행합니다. t.Error()를 내부적으로 호출합니다.
  • require.Equal: 실패하면 테스트를 즉시 중단합니다. t.FailNow()를 내부적으로 호출합니다.

설명:

require는 이후 코드가 전제 조건에 의존할 때 사용합니다:

func TestUserCreation(t *testing.T) {
    user, err := CreateUser("Alice", "alice@example.com")
    require.NoError(t, err)     // 에러가 있으면 user가 nil이므로 이후 코드가 패닉
    require.NotNil(t, user)

    assert.Equal(t, "Alice", user.Name)  // 이후 검증은 assert
    assert.NotEmpty(t, user.ID)
}

assert는 독립적으로 검증할 수 있는 여러 항목을 모두 확인할 때 사용합니다.

퀴즈 2: 테이블 주도 테스트의 클로저 함정

다음 테이블 주도 테스트의 버그는 무엇인가요?

tests := []struct {
    name string
    input int
    want int
}{
    {"double 2", 2, 4},
    {"double 5", 5, 10},
    {"double 10", 10, 20},
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := Double(tt.input)
        assert.Equal(t, tt.want, result)
    })
}

정답: t.Parallel() 사용 시 클로저가 루프 변수 tt를 공유합니다.

설명: t.Parallel()을 호출하면 테스트가 병렬로 실행되는데, 이때 클로저 내에서 tt를 참조하면 모든 고루틴이 같은 변수를 공유합니다. 루프가 빠르게 끝나면 마지막 값만 남아 모든 테스트가 같은 입력으로 실행될 수 있습니다.

수정 방법:

for _, tt := range tests {
    tt := tt  // 루프 변수를 로컬 변수로 캡처
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := Double(tt.input)
        assert.Equal(t, tt.want, result)
    })
}

Go 1.22 이후에는 루프 변수 동작이 변경되어 이 문제가 없어졌습니다.

퀴즈 3: Mock vs Stub 차이

Mock과 Stub의 차이점을 설명하고, 언제 각각을 사용해야 하나요?

정답:

  • Stub: 미리 정해진 응답을 반환하는 단순한 대체물. 상태 검증(state verification)에 사용.
  • Mock: 특정 메서드가 특정 인수로 호출되었는지 검증하는 객체. 행위 검증(behavior verification)에 사용.

설명:

Stub 예시 — 고정 응답 반환:

type StubUserRepo struct{}
func (s StubUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
    return &User{ID: id, Name: "Test User"}, nil
}

Mock 예시 — 호출 검증:

mockRepo.EXPECT().FindByID(ctx, 1).Return(user, nil).Times(1)
// 테스트 후 FindByID가 정확히 1번 호출되었는지 자동 검증

규칙: 테스트가 메서드 호출 여부/횟수/인수를 검증해야 한다면 Mock, 단순히 특정 응답이 필요하다면 Stub을 사용합니다.

퀴즈 4: go test -race 플래그의 역할

go test -race는 무엇을 감지하고, 언제 사용해야 하나요?

정답: 레이스 컨디션(Race Condition)을 감지합니다. 여러 고루틴이 동기화 없이 같은 메모리 위치에 접근할 때를 탐지합니다.

설명:

// 이 코드는 -race 플래그로 감지됨
var counter int

func TestRaceCondition(t *testing.T) {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // DATA RACE: 동기화 없이 쓰기
        }()
    }
    wg.Wait()
}

-race 플래그는 런타임 오버헤드가 크므로 (5~10배 느려짐) CI/CD 파이프라인에서 실행하거나, 동시성 코드를 작성할 때 주기적으로 사용하는 것이 좋습니다.

퀴즈 5: 테스트 커버리지 100%의 함정

테스트 커버리지 100%가 코드에 버그가 없음을 보장하나요? 커버리지 측정 시 주의해야 할 점은?

정답: 아니요. 커버리지 100%는 버그가 없음을 보장하지 않습니다.

설명: 커버리지는 코드 실행 여부를 측정하지, 올바른 동작을 검증하지 않습니다.

func Divide(a, b int) int {
    return a / b
}

// 커버리지 100%이지만 중요한 케이스를 놓침
func TestDivide(t *testing.T) {
    assert.Equal(t, 5, Divide(10, 2))
    // b=0 케이스를 테스트하지 않아 패닉 발생 가능
}

커버리지보다 중요한 것들:

  1. 경계값 테스트 (0, -1, max값 등)
  2. 에러 케이스 테스트
  3. 동시성 테스트
  4. 통합 테스트

커버리지는 테스트하지 않은 코드를 찾는 도구이지, 테스트 품질을 보장하지 않습니다.