목(Mock) & 인터페이스 테스트
Go에서 외부 의존성(DB, API, 파일 시스템)을 테스트에서 격리하려면 인터페이스 와 목(Mock) 을 활용합니다.
왜 목이 필요한가?
단위 테스트는 빠르고 예측 가능 해야 합니다. 실제 DB나 외부 API는:
- 느리고 (네트워크 왕복)
- 불안정하고 (외부 서비스 다운)
- 상태 의존적입니다 (테스트 순서에 영향)
목(Mock)은 이 문제를 인터페이스로 해결합니다.
인터페이스 기반 설계
// ✅ 인터페이스로 의존성 정의
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id int64) error
}
type EmailService interface {
SendWelcome(email, name string) error
SendPasswordReset(email, token string) error
}
// 서비스는 인터페이스에 의존 (구현 아님)
type UserService struct {
repo UserRepository
email EmailService
}
func NewUserService(repo UserRepository, email EmailService) *UserService {
return &UserService{repo: repo, email: email}
}
func (s *UserService) Register(ctx context.Context, name, email, password string) (*User, error) {
// 이메일 중복 확인
existing, err := s.repo.FindByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("이메일 확인 실패: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("이미 사용 중인 이메일입니다")
}
user := &User{Name: name, Email: email}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("사용자 생성 실패: %w", err)
}
// 환영 이메일 전송 (실패해도 가입 성공)
_ = s.email.SendWelcome(email, name)
return user, nil
}
수동 목(Manual Mock) 작성
가장 단순한 방법: 인터페이스를 직접 구현하는 테스트용 구조체를 작성합니다.
// 수동 목: UserRepository 인터페이스 구현
type mockUserRepository struct {
users map[int64]*User
nextID int64
// 에러 시뮬레이션용 필드
findError error
createError error
callCount map[string]int
}
func newMockUserRepo() *mockUserRepository {
return &mockUserRepository{
users: make(map[int64]*User),
nextID: 1,
callCount: make(map[string]int),
}
}
func (m *mockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
m.callCount["FindByID"]++
if m.findError != nil {
return nil, m.findError
}
user, ok := m.users[id]
if !ok {
return nil, nil
}
return user, nil
}
func (m *mockUserRepository) Create(ctx context.Context, user *User) error {
m.callCount["Create"]++
if m.createError != nil {
return m.createError
}
user.ID = m.nextID
m.users[m.nextID] = user
m.nextID++
return nil
}
// mockEmailService
type mockEmailService struct {
sentEmails []struct{ To, Subject string }
sendError error
}
func (m *mockEmailService) SendWelcome(email, name string) error {
m.sentEmails = append(m.sentEmails, struct{ To, Subject string }{email, "welcome"})
return m.sendError
}
func (m *mockEmailService) SendPasswordReset(email, token string) error {
m.sentEmails = append(m.sentEmails, struct{ To, Subject string }{email, "reset"})
return m.sendError
}
수동 목 사용 예
func TestUserService_Register(t *testing.T) {
t.Run("정상 회원가입", func(t *testing.T) {
repo := newMockUserRepo()
emailSvc := &mockEmailService{}
svc := NewUserService(repo, emailSvc)
user, err := svc.Register(context.Background(), "김고랭", "golang@example.com", "pass")
assert.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, "김고랭", user.Name)
assert.Equal(t, 1, repo.callCount["Create"])
assert.Len(t, emailSvc.sentEmails, 1)
})
t.Run("DB 에러 시 실패", func(t *testing.T) {
repo := newMockUserRepo()
repo.createError = errors.New("DB 오류")
emailSvc := &mockEmailService{}
svc := NewUserService(repo, emailSvc)
_, err := svc.Register(context.Background(), "테스트", "test@example.com", "pass")
assert.Error(t, err)
assert.Contains(t, err.Error(), "사용자 생성 실패")
// 이메일은 보내지지 않아야 함
assert.Empty(t, emailSvc.sentEmails)
})
}
testify/mock — 자동 Mock 생성
testify/mock은 호출 검증, 인수 매칭, 반환 값 설정을 쉽게 합니다.
go get github.com/stretchr/testify/mock
import "github.com/stretchr/testify/mock"
// Mock 구조체: mock.Mock 임베딩
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
// Called()로 호출 기록, 반환 값 설정
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepository) Create(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func TestWithTestifyMock(t *testing.T) {
ctx := context.Background()
repo := new(MockUserRepository)
emailSvc := new(MockEmailService)
// FindByEmail: nil 반환 (이메일 중복 없음)
repo.On("FindByEmail", ctx, "golang@example.com").Return(nil, nil)
// Create: 성공 (user.ID를 1로 설정하는 함수 사용)
repo.On("Create", ctx, mock.AnythingOfType("*main.User")).
Run(func(args mock.Arguments) {
user := args.Get(1).(*User)
user.ID = 1
}).
Return(nil)
emailSvc.On("SendWelcome", "golang@example.com", "김고랭").Return(nil)
svc := NewUserService(repo, emailSvc)
user, err := svc.Register(ctx, "김고랭", "golang@example.com", "pass")
assert.NoError(t, err)
assert.Equal(t, int64(1), user.ID)
// 모든 설정된 호출이 실제로 이루어졌는지 검증
repo.AssertExpectations(t)
emailSvc.AssertExpectations(t)
// 특정 함수 호출 횟수 검증
repo.AssertNumberOfCalls(t, "Create", 1)
}
gomock — 코드 생성 기반 Mock
go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/gomock
인터페이스 파일에서 자동 생성
// interfaces.go
package service
//go:generate mockgen -source=interfaces.go -destination=mocks/mock_interfaces.go -package=mocks
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
}
go generate ./...
생성된 목 사용:
import (
"testing"
"go.uber.org/mock/gomock"
"myapp/mocks"
)
func TestWithGomock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish() // 미호출 예상 검증
repo := mocks.NewMockUserRepository(ctrl)
// 기대값 설정
repo.EXPECT().
FindByID(gomock.Any(), int64(1)).
Return(&User{ID: 1, Name: "테스트"}, nil).
Times(1)
svc := NewUserService(repo, nil)
user, err := svc.GetUser(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, "테스트", user.Name)
}
의존성 주입 패턴 — 테스트 용이성 극대화
// 함수 타입으로 의존성 주입
type NotifyFunc func(userID int64, message string) error
type OrderService struct {
db OrderRepository
notify NotifyFunc
}
func NewOrderService(db OrderRepository, notify NotifyFunc) *OrderService {
return &OrderService{db: db, notify: notify}
}
// 테스트에서 함수 교체
func TestOrderService(t *testing.T) {
var notified []struct{ ID int64; Msg string }
mockNotify := func(userID int64, message string) error {
notified = append(notified, struct{ ID int64; Msg string }{userID, message})
return nil
}
svc := NewOrderService(mockRepo, mockNotify)
svc.PlaceOrder(context.Background(), 1, 100.0)
assert.Len(t, notified, 1)
assert.Equal(t, int64(1), notified[0].ID)
}
핵심 정리
| 접근법 | 장점 | 단점 |
|---|---|---|
| 수동 Mock | 간단, 의존성 없음 | 반복 코드 많음 |
| testify/mock | 유연한 인수 매칭 | 런타임 검증 |
| gomock | 컴파일 타임 타입 체크 | 코드 생성 필요 |
인터페이스 설계 원칙:
- 인터페이스는 사용하는 쪽 에서 정의 (Consumer-Driven)
- 작게 유지 — 1~3개 메서드가 이상적
- 구현체가 하나뿐이어도 테스트를 위해 인터페이스 고려