Skip to content

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

|

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

소개

소프트웨어 품질은 테스트로 증명됩니다. 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. 통합 테스트

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

Go Testing Complete Guide: Unit Tests, Mocks, and Stubs

Introduction

Software quality is proven through tests. Go ships with powerful testing tools built into the language itself — the testing package and go test command alone are enough to write production-grade tests.

This guide covers practical, immediately applicable patterns from basic unit tests to dependency isolation with mocks and stubs, all the way through integration testing.


1. Go Testing Basics

1.1 The testing Package

Go tests are written in _test.go files, and test functions start with the Test prefix.

// calculator.go
package calculator

import "fmt"

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: stops execution of the current test immediately
func TestDiv(t *testing.T) {
    result, err := Div(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Div(10, 2) = %f; want 5.0", result)
    }

    // Test division by zero
    _, err = Div(10, 0)
    if err == nil {
        t.Error("Div by zero should return an error")
    }
}

// t.Helper(): marks a function as a test helper so error lines point to the caller
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 Running Tests

# Test current package
go test

# Test all packages
go test ./...

# Verbose output
go test -v

# Run only specific tests (regex)
go test -run TestAdd
go test -run TestDiv

# Coverage
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out    # open in browser
go tool cover -func=coverage.out    # per-function coverage

# Set timeout
go test -timeout 30s

# Detect race conditions
go test -race

# Disable caching
go test -count=1 ./...

1.3 TestMain

package mypackage_test

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

var testDB *Database

// TestMain: runs setup and teardown around the entire test suite
func TestMain(m *testing.M) {
    // Setup
    fmt.Println("Setting up test environment")
    testDB = setupTestDatabase()

    // Run tests
    code := m.Run()

    // Teardown
    testDB.Close()
    fmt.Println("Test environment cleaned up")

    os.Exit(code)
}

2. Table-Driven Tests

The most idiomatic testing pattern in 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
    }{
        {"positive + positive", 2, 3, 5},
        {"positive + negative", 5, -3, 2},
        {"negative + negative", -4, -6, -10},
        {"zero case", 0, 0, 0},
        {"large numbers", 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)
            }
        })
    }
}

// Table test with error cases
func TestDiv_TableDriven(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"normal division", 10, 2, 5.0, false},
        {"fractional result", 7, 2, 3.5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative dividend", -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("expected error, got nil")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Div(%.1f, %.1f) = %.1f; want %.1f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

// Per-subtest setup and teardown
func TestUserService_TableDriven(t *testing.T) {
    tests := []struct {
        name    string
        userID  int
        wantErr bool
    }{
        {"valid user", 1, false},
        {"user not found", 999, true},
        {"negative ID", -1, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Per-subtest 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("user: %+v\n", user)
            }
        })
    }
}

3. The testify Library

testify is the most widely used testing library in 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: test continues even on failure
    assert.Equal(t, 5, result, "2+3 should equal 5")
    assert.NotEqual(t, 6, result)
    assert.True(t, result > 0)
    assert.False(t, result < 0)

    // require: test stops immediately on failure
    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 and HTTP Assertions

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 comparison regardless of key order
    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
}

// Runs once before the entire suite
func (s *UserServiceSuite) SetupSuite() {
    s.db = NewTestDB()
}

// Runs before each test
func (s *UserServiceSuite) SetupTest() {
    s.service = NewUserService(s.db)
    s.db.Seed()
}

// Runs after each test
func (s *UserServiceSuite) TearDownTest() {
    s.db.Clean()
}

// Runs once after the entire suite
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")
}

// Register the suite with the Go test runner
func TestUserServiceSuite(t *testing.T) {
    suite.Run(t, new(UserServiceSuite))
}

4. Interface-Based Mocks

4.1 Dependency Injection via Interfaces

// user_repository.go
package user

import (
    "context"
    "fmt"
)

type User struct {
    ID    int
    Name  string
    Email string
}

// Interface definition (the key to dependency injection)
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
}

// Service depends on interfaces, not concrete implementations
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)
    }
    // Send welcome email (failure doesn't affect user creation)
    _ = s.email.Send(email, "Welcome!", "Thank you for signing up!")
    return user, nil
}

4.2 Manual Mock Implementation

// Can be defined directly in test files
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. Using gomock

5.1 Installing gomock and Generating Mocks

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

Add a go:generate directive to your interface file:

// 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)
}
# Generate mocks
go generate ./...

5.2 Writing Tests with gomock

package user_test

import (
    "context"
    "fmt"
    "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: set up call expectations
    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 must be called exactly once
    mockRepo.EXPECT().
        Save(gomock.Any(), gomock.Any()).
        DoAndReturn(func(ctx context.Context, u *user.User) error {
            u.ID = 42 // simulate ID assignment
            return nil
        }).
        Times(1)

    // Send can be called zero or more times
    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)
}

// Using specific argument matchers
func TestFindByID_SpecificArgs(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)

    // Exact argument matching
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Eq(5)).
        Return(nil, fmt.Errorf("not found")).
        Times(1)

    // Custom matcher
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Not(gomock.Eq(0))).
        Return(&user.User{ID: 10}, nil).
        AnyTimes()
}

6. Using testify/mock

6.1 Implementing Mocks with testify/mock

package user_test

import (
    "context"
    "testing"

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

// Mock struct embedding 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"}

    // Use On() to configure behavior
    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)

    // Verify all expectations were met
    mockRepo.AssertExpectations(t)
}

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

    // Use mock.MatchedBy for custom matchers
    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 Server (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 (Mocking External APIs)

package client_test

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

func TestHTTPClient_GetUser(t *testing.T) {
    // Test server that mocks an external service
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Validate the 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()

    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 Patterns

8.1 Interface-Based Stubs

// Time Stub: making time.Now() replaceable
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 }

// File System 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)
}

// In-memory file system 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
}

// Service test using stubs
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 Database Stub (sqlmock)

package repository_test

import (
    "context"
    "regexp"
    "testing"

    "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("failed to create 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. Building a Mock Server

// mockserver/server.go
package mockserver

import (
    "encoding/json"
    "io"
    "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("unexpected request: %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: request count = %d, want %d", method, path, actual, count)
    }
}

// Usage example
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 Tests

package calculator_test

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

// Benchmark: run with 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)
            }
        })
    }
}

// Track memory allocations
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. Quizzes

Quiz 1: assert vs require

What is the difference between assert.Equal and require.Equal? When should you use each?

Answer:

  • assert.Equal: test continues running even on failure. Calls t.Error() internally.
  • require.Equal: test stops immediately on failure. Calls t.FailNow() internally.

Explanation:

Use require when subsequent code depends on a precondition:

func TestUserCreation(t *testing.T) {
    user, err := CreateUser("Alice", "alice@example.com")
    require.NoError(t, err)  // if error, user is nil, next line would panic
    require.NotNil(t, user)

    assert.Equal(t, "Alice", user.Name)  // independent assertions use assert
    assert.NotEmpty(t, user.ID)
}

Use assert when you want to check multiple independent conditions and see all failures at once.

Quiz 2: Table-Driven Test Closure Trap

What is the bug in the following table-driven test?

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)
    })
}

Answer: With t.Parallel(), the closure shares the loop variable tt.

Explanation: When t.Parallel() is called, the subtest runs concurrently. The closure captures tt by reference, so all goroutines share the same variable. By the time they run, the loop has finished and tt holds the last value.

Fix:

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

In Go 1.22+, loop variable semantics changed and this issue no longer applies.

Quiz 3: Mock vs Stub

Explain the difference between a Mock and a Stub, and when to use each.

Answer:

  • Stub: a simple replacement that returns pre-configured responses. Used for state verification.
  • Mock: an object that verifies specific methods were called with specific arguments. Used for behavior verification.

Explanation:

Stub example — fixed response:

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

Mock example — call verification:

mockRepo.EXPECT().FindByID(ctx, 1).Return(user, nil).Times(1)
// After the test, automatically verifies FindByID was called exactly once

Rule of thumb: use a Mock when your test needs to verify whether/how many times/with what arguments a method was called. Use a Stub when you simply need a certain response to be returned.

Quiz 4: The go test -race Flag

What does go test -race detect, and when should you use it?

Answer: It detects race conditions — situations where multiple goroutines access the same memory location concurrently without synchronization.

Explanation:

// This code is caught by -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: unsynchronized write
        }()
    }
    wg.Wait()
}

The -race flag has significant runtime overhead (5–10x slower), so it's best used in CI/CD pipelines or periodically when writing concurrent code rather than on every local test run.

Quiz 5: The 100% Coverage Trap

Does 100% test coverage guarantee that code is bug-free? What are the pitfalls of measuring coverage?

Answer: No. 100% coverage does not guarantee the absence of bugs.

Explanation: Coverage measures whether code was executed, not whether it behaves correctly.

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

// 100% line coverage but misses a critical case
func TestDivide(t *testing.T) {
    assert.Equal(t, 5, Divide(10, 2))
    // No test for b=0, which causes a panic at runtime
}

More important than coverage numbers:

  1. Boundary value testing (0, -1, max values, etc.)
  2. Error path testing
  3. Concurrency testing
  4. Integration testing

Coverage is a tool to find untested code paths, not a measure of test quality. A test suite with 70% meaningful coverage is far more valuable than one with 100% trivial coverage.