본문으로 건너뛰기

목(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개 메서드가 이상적
  • 구현체가 하나뿐이어도 테스트를 위해 인터페이스 고려