Go의 testing 패키지를 중심으로 테이블 기반 테스트, testify, httptest, 목(Mock) 생성, testcontainers-go 통합 테스트, 벤치마크, 퍼징 테스트 전략을 다룹니다.
Go는 테스트를 언어와 도구 체인 수준에서 지원합니다. 별도의 테스트 프레임워크 설치 없이 go test 명령으로 실행할 수 있습니다.
package service
import "testing"
func TestUserService_ValidateEmail(t *testing.T) {
svc := NewUserService(nil)
// 유효한 이메일
err := svc.ValidateEmail("user@example.com")
if err != nil {
t.Errorf("유효한 이메일인데 에러 발생: %v", err)
}
// 잘못된 이메일
err = svc.ValidateEmail("invalid-email")
if err == nil {
t.Error("잘못된 이메일인데 에러가 발생하지 않음")
}
}# 현재 패키지 테스트
go test ./...
# 상세 출력
go test -v ./...
# 특정 테스트만 실행
go test -run TestUserService_ValidateEmail ./service/...
# 커버리지 측정
go test -cover ./...
# 커버리지 리포트 생성
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.outGo 테스트 파일은 _test.go 접미사를 가지며, 빌드 시 자동으로 제외됩니다. 테스트 함수는 반드시 Test로 시작하고 *testing.T 매개변수를 받아야 합니다.
테이블 기반 테스트(Table-driven Test)는 Go 커뮤니티에서 가장 권장되는 테스트 패턴입니다. 여러 테스트 케이스를 구조체 슬라이스로 정의하고 루프에서 실행합니다.
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
price float64
quantity int
want float64
wantErr bool
}{
{
name: "일반 주문 -- 할인 없음",
price: 10000,
quantity: 1,
want: 10000,
},
{
name: "대량 주문 -- 10% 할인",
price: 10000,
quantity: 10,
want: 90000,
},
{
name: "VIP 대량 주문 -- 20% 할인",
price: 10000,
quantity: 100,
want: 800000,
},
{
name: "음수 수량 -- 에러",
price: 10000,
quantity: -1,
wantErr: true,
},
{
name: "가격 0 -- 에러",
price: 0,
quantity: 1,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CalculateDiscount(tt.price, tt.quantity)
if tt.wantErr {
if err == nil {
t.Error("에러를 기대했으나 발생하지 않음")
}
return
}
if err != nil {
t.Fatalf("예상하지 못한 에러: %v", err)
}
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}t.Run을 사용하면 각 서브테스트가 독립적으로 실행되며, go test -run "TestCalculateDiscount/대량" 처럼 특정 서브테스트만 실행할 수도 있습니다.
testify는 Go에서 가장 많이 사용되는 테스트 보조 라이브러리로, 간결한 단언(Assertion)과 목(Mock) 기능을 제공합니다.
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserService_CreateUser(t *testing.T) {
svc := setupTestService(t)
user, err := svc.CreateUser(ctx, CreateUserRequest{
Name: "Kreath",
Email: "kreath@example.com",
})
// require -- 실패 시 즉시 테스트 중단
require.NoError(t, err)
require.NotNil(t, user)
// assert -- 실패해도 나머지 단언 계속 실행
assert.Equal(t, "Kreath", user.Name)
assert.Equal(t, "kreath@example.com", user.Email)
assert.NotZero(t, user.ID)
assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second)
}require는 실패 시 테스트를 즉시 중단하고, assert는 실패해도 나머지 단언을 계속 실행합니다. 후속 단언이 의미 없어지는 경우(예: nil 체크) require를 사용하고, 독립적인 검증에는 assert를 사용하세요.
import "github.com/stretchr/testify/mock"
// 목 객체 정의
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
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) Save(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
// 테스트에서 목 사용
func TestUserService_GetUser(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
expectedUser := &User{ID: 1, Name: "Kreath"}
mockRepo.On("FindByID", mock.Anything, int64(1)).Return(expectedUser, nil)
user, err := svc.GetUser(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, expectedUser, user)
mockRepo.AssertExpectations(t)
}
// 에러 시나리오 테스트
func TestUserService_GetUser_NotFound(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
mockRepo.On("FindByID", mock.Anything, int64(999)).
Return(nil, ErrNotFound)
user, err := svc.GetUser(context.Background(), 999)
assert.Nil(t, user)
assert.ErrorIs(t, err, ErrNotFound)
}net/http/httptest 패키지는 HTTP 핸들러를 실제 서버 없이 테스트할 수 있게 합니다.
import "net/http/httptest"
func TestGetUserHandler(t *testing.T) {
// 목 서비스 설정
mockSvc := new(MockUserService)
handler := NewUserHandler(mockSvc)
expectedUser := &User{ID: 1, Name: "Kreath", Email: "kreath@example.com"}
mockSvc.On("GetUser", mock.Anything, int64(1)).Return(expectedUser, nil)
// 요청 생성
req := httptest.NewRequest("GET", "/api/v1/users/1", nil)
req.SetPathValue("id", "1") // Go 1.22+ 경로 파라미터
// 응답 레코더
rec := httptest.NewRecorder()
// 핸들러 실행
handler.GetUser(rec, req)
// 검증
assert.Equal(t, http.StatusOK, rec.Code)
var response User
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, "Kreath", response.Name)
}
func TestGetUserHandler_NotFound(t *testing.T) {
mockSvc := new(MockUserService)
handler := NewUserHandler(mockSvc)
mockSvc.On("GetUser", mock.Anything, int64(999)).
Return(nil, ErrNotFound)
req := httptest.NewRequest("GET", "/api/v1/users/999", nil)
req.SetPathValue("id", "999")
rec := httptest.NewRecorder()
handler.GetUser(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
}실제 라우터와 미들웨어 스택을 포함한 통합 테스트입니다.
func TestAPI_Integration(t *testing.T) {
// 전체 앱 설정 (테스트 DB 사용)
app := setupTestApp(t)
// 테스트 서버 생성
server := httptest.NewServer(app.Router())
defer server.Close()
client := server.Client()
// 회원가입
body := `{"name": "Test", "email": "test@example.com", "password": "secure123"}`
resp, err := client.Post(server.URL+"/api/v1/auth/register",
"application/json", strings.NewReader(body))
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
// 로그인
loginBody := `{"email": "test@example.com", "password": "secure123"}`
resp, err = client.Post(server.URL+"/api/v1/auth/login",
"application/json", strings.NewReader(loginBody))
require.NoError(t, err)
var loginResp LoginResponse
json.NewDecoder(resp.Body).Decode(&loginResp)
assert.NotEmpty(t, loginResp.AccessToken)
}testcontainers-go는 테스트에서 Docker 컨테이너를 프로그래밍 방식으로 관리합니다. 실제 PostgreSQL, Redis 등을 테스트 환경에서 사용할 수 있습니다.
import (
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupTestDB(t *testing.T) *pgxpool.Pool {
t.Helper()
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, pgContainer.Terminate(ctx))
})
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
pool, err := pgxpool.New(ctx, connStr)
require.NoError(t, err)
// 마이그레이션 실행
runMigrations(t, connStr)
return pool
}
func TestUserRepo_Integration(t *testing.T) {
if testing.Short() {
t.Skip("통합 테스트 건너뜀 (-short 플래그)")
}
pool := setupTestDB(t)
repo := NewUserRepo(pool)
ctx := context.Background()
// 생성
user := &User{Name: "Kreath", Email: "kreath@test.com"}
err := repo.Create(ctx, user)
require.NoError(t, err)
assert.NotZero(t, user.ID)
// 조회
found, err := repo.FindByID(ctx, user.ID)
require.NoError(t, err)
assert.Equal(t, "Kreath", found.Name)
}testing.Short() 플래그를 활용하면 CI 환경에서는 전체 테스트를, 로컬 개발 시에는 빠른 단위 테스트만 실행할 수 있습니다. go test -short ./...으로 Docker 의존성이 있는 통합 테스트를 건너뛸 수 있습니다.
func BenchmarkJSONMarshal(b *testing.B) {
user := User{
ID: 1,
Name: "Kreath",
Email: "kreath@example.com",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
json.Marshal(user)
}
}
func BenchmarkJSONMarshal_Large(b *testing.B) {
users := make([]User, 1000)
for i := range users {
users[i] = User{ID: int64(i), Name: "User", Email: "user@test.com"}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
json.Marshal(users)
}
}# 벤치마크 실행
go test -bench=. -benchmem ./...
# 특정 벤치마크만
go test -bench=BenchmarkJSONMarshal -benchmem ./...
# 결과 예시
# BenchmarkJSONMarshal-8 5000000 234 ns/op 128 B/op 2 allocs/opfunc BenchmarkMapAccess(b *testing.B) {
m := make(map[string]int)
for i := 0; i < 1000; i++ {
m[fmt.Sprintf("key-%d", i)] = i
}
b.Run("map", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = m["key-500"]
}
})
b.Run("sync.Map", func(b *testing.B) {
var sm sync.Map
for k, v := range m {
sm.Store(k, v)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sm.Load("key-500")
}
})
}Go 1.18에서 도입된 퍼징(Fuzzing)은 무작위 입력을 생성하여 예상하지 못한 버그를 발견합니다.
func FuzzParseUserInput(f *testing.F) {
// 시드 코퍼스 추가
f.Add("kreath@example.com")
f.Add("invalid")
f.Add("")
f.Add("a@b.c")
f.Add(strings.Repeat("a", 1000) + "@test.com")
f.Fuzz(func(t *testing.T, input string) {
result, err := ParseUserInput(input)
if err != nil {
return // 에러는 허용
}
// 성공했다면 결과가 유효해야 함
if result.Email != "" && !strings.Contains(result.Email, "@") {
t.Errorf("유효하지 않은 이메일이 통과됨: %s", result.Email)
}
})
}# 30초간 퍼징 실행
go test -fuzz=FuzzParseUserInput -fuzztime=30s ./...퍼징은 파서, 직렬화/역직렬화, 유효성 검사 로직에서 특히 효과적입니다. 개발자가 미처 생각하지 못한 엣지 케이스를 자동으로 발견해줍니다.
단위 테스트 (70%)
- 비즈니스 로직, 유틸리티 함수
- 의존성은 목으로 대체
- 실행 시간: 밀리초 단위
통합 테스트 (20%)
- 데이터베이스, 외부 서비스 연동
- testcontainers 또는 테스트 DB 사용
- 실행 시간: 초 단위
E2E 테스트 (10%)
- 전체 API 흐름 검증
- httptest.NewServer 활용
- 실행 시간: 수초 단위이번 장에서 살펴본 핵심 내용을 정리합니다.
assert/require와 mock은 테스트 코드를 간결하고 표현력 있게 만듭니다10장에서는 gRPC와 마이크로서비스 통신을 다룹니다. Protocol Buffers, gRPC의 유니어리/스트리밍 통신, 인터셉터, gRPC-Gateway를 통한 REST 변환, 헬스 체킹과 서비스 디스커버리를 살펴봅니다.
이 글이 도움이 되셨나요?
Protocol Buffers 정의부터 Go에서의 gRPC 서버/클라이언트 구현, 유니어리/스트리밍 통신, 인터셉터, gRPC-Gateway를 통한 REST 변환, 헬스 체킹과 서비스 디스커버리를 다룹니다.
Go 백엔드의 미들웨어 패턴, JWT 인증 구현, CORS 설정, 레이트 리미팅, 요청 유효성 검사, OpenAPI/Swagger 문서 생성, log/slog를 활용한 구조화된 로깅을 다룹니다.
시리즈의 모든 내용을 종합하여 Chi 기반 REST API 서버, PostgreSQL(pgx), JWT 인증, 미들웨어 스택, 구조화된 로깅, 테스트, Docker 빌드, CI/CD 파이프라인을 갖춘 완전한 백엔드를 구축합니다.