- Authors

- Name
- Youngju Kim
- @fjvbn20031
소개
소프트웨어 품질은 테스트로 증명됩니다. 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.Equal과 require.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 케이스를 테스트하지 않아 패닉 발생 가능
}
커버리지보다 중요한 것들:
- 경계값 테스트 (0, -1, max값 등)
- 에러 케이스 테스트
- 동시성 테스트
- 통합 테스트
커버리지는 테스트하지 않은 코드를 찾는 도구이지, 테스트 품질을 보장하지 않습니다.