HTTP 핸들러 테스트 — httptest로 통합 테스트
Go 표준 라이브러리의 net/http/httptest 패키지를 사용하면 실제 서버를 띄우지 않고도 HTTP 핸들러를 완전히 테스트할 수 있습니다.
httptest.Recorder — 핸들러 단위 테스트
// handler.go
package handler
type UserHandler struct {
svc UserService
}
func NewUserHandler(svc UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") // Go 1.22+
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "잘못된 ID", http.StatusBadRequest)
return
}
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
http.Error(w, "서버 에러", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "사용자 없음", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "잘못된 요청 바디", http.StatusBadRequest)
return
}
user, err := h.svc.CreateUser(r.Context(), req.Name, req.Email)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
httptest.Recorder로 핸들러 테스트
// handler_test.go
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetUser(t *testing.T) {
tests := []struct {
name string
id string
mockUser *User
mockError error
wantStatus int
}{
{
name: "존재하는 사용자 조회",
id: "1",
mockUser: &User{ID: 1, Name: "김고랭", Email: "golang@example.com"},
wantStatus: http.StatusOK,
},
{
name: "존재하지 않는 사용자",
id: "999",
mockUser: nil,
wantStatus: http.StatusNotFound,
},
{
name: "잘못된 ID",
id: "abc",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mock 서비스 설정
mockSvc := new(MockUserService)
if tt.id != "abc" {
id, _ := strconv.ParseInt(tt.id, 10, 64)
mockSvc.On("GetUser", mock.Anything, id).
Return(tt.mockUser, tt.mockError)
}
h := NewUserHandler(mockSvc)
// 요청 생성
req := httptest.NewRequest(http.MethodGet, "/users/"+tt.id, nil)
req.SetPathValue("id", tt.id) // Go 1.22+
// 응답 레코더
w := httptest.NewRecorder()
// 핸들러 호출
h.GetUser(w, req)
// 검증
resp := w.Result()
assert.Equal(t, tt.wantStatus, resp.StatusCode)
if tt.wantStatus == http.StatusOK {
var user User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&user))
assert.Equal(t, tt.mockUser.Name, user.Name)
}
})
}
}
func TestCreateUser(t *testing.T) {
t.Run("사용자 생성 성공", func(t *testing.T) {
mockSvc := new(MockUserService)
mockSvc.On("CreateUser", mock.Anything, "김고랭", "golang@example.com").
Return(&User{ID: 1, Name: "김고랭", Email: "golang@example.com"}, nil)
h := NewUserHandler(mockSvc)
body := `{"name":"김고랭","email":"golang@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.CreateUser(w, req)
resp := w.Result()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var created User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
assert.Equal(t, int64(1), created.ID)
})
t.Run("잘못된 JSON 바디", func(t *testing.T) {
h := NewUserHandler(new(MockUserService))
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBufferString("invalid json"))
w := httptest.NewRecorder()
h.CreateUser(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
httptest.Server — 전체 서버 통합 테스트
실제 HTTP 서버를 임시로 띄워 클라이언트까지 함께 테스트합니다.
func TestServerIntegration(t *testing.T) {
// 테스트용 라우터 설정
mux := http.NewServeMux()
svc := &realUserService{db: setupTestDB(t)}
h := NewUserHandler(svc)
mux.HandleFunc("GET /users/{id}", h.GetUser)
mux.HandleFunc("POST /users", h.CreateUser)
// 임시 테스트 서버 생성 (랜덤 포트)
ts := httptest.NewServer(mux)
defer ts.Close()
client := ts.Client() // 테스트 서버와 통신하는 HTTP 클라이언트
t.Run("사용자 생성 후 조회", func(t *testing.T) {
// 생성
body := `{"name":"통합테스트","email":"integration@example.com"}`
resp, err := client.Post(ts.URL+"/users",
"application/json",
bytes.NewBufferString(body))
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var created User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
resp.Body.Close()
// 조회
getResp, err := client.Get(fmt.Sprintf("%s/users/%d", ts.URL, created.ID))
require.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
var found User
require.NoError(t, json.NewDecoder(getResp.Body).Decode(&found))
assert.Equal(t, created.ID, found.ID)
assert.Equal(t, "통합테스트", found.Name)
})
}
Gin/Echo 핸들러 테스트
Gin 테스트
import "github.com/gin-gonic/gin"
func TestGinHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
mockSvc := new(MockUserService)
h := NewUserHandler(mockSvc)
router.GET("/users/:id", h.GetUserGin)
router.POST("/users", h.CreateUserGin)
tests := []struct {
name string
method string
path string
body string
wantStatus int
}{
{"존재하는 사용자", "GET", "/users/1", "", http.StatusOK},
{"없는 사용자", "GET", "/users/999", "", http.StatusNotFound},
{"사용자 생성", "POST", "/users", `{"name":"테스트","email":"t@test.com"}`, http.StatusCreated},
}
// Mock 설정
mockSvc.On("GetUser", mock.Anything, int64(1)).
Return(&User{ID: 1, Name: "테스트"}, nil)
mockSvc.On("GetUser", mock.Anything, int64(999)).
Return(nil, nil)
mockSvc.On("CreateUser", mock.Anything, "테스트", "t@test.com").
Return(&User{ID: 1, Name: "테스트"}, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bodyReader *bytes.Buffer
if tt.body != "" {
bodyReader = bytes.NewBufferString(tt.body)
} else {
bodyReader = bytes.NewBufferString("")
}
req := httptest.NewRequest(tt.method, tt.path, bodyReader)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
})
}
}
미들웨어 테스트
func TestAuthMiddleware(t *testing.T) {
t.Run("유효한 토큰", func(t *testing.T) {
handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID")
fmt.Fprintf(w, "user:%v", userID)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer valid-token-123")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "user:")
})
t.Run("토큰 없음", func(t *testing.T) {
handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("핸들러가 호출되면 안 됨")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
testcontainers로 DB 통합 테스트
실제 PostgreSQL 컨테이너를 사용한 완전한 통합 테스트입니다.
func TestUserHandlerWithRealDB(t *testing.T) {
if testing.Short() {
t.Skip("통합 테스트: -short 플래그로 건너뜀")
}
ctx := context.Background()
// PostgreSQL 컨테이너 시작
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),
),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
// DB 연결 및 마이그레이션
dsn, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)
runMigrations(t, db)
// 실제 서비스 & 핸들러 구성
repo := NewUserRepository(db)
svc := NewUserService(repo)
h := NewUserHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("POST /users", h.CreateUser)
mux.HandleFunc("GET /users/{id}", h.GetUser)
ts := httptest.NewServer(mux)
defer ts.Close()
// E2E 시나리오
t.Run("E2E: 생성 → 조회", func(t *testing.T) {
// 생성
body := `{"name":"E2E테스트","email":"e2e@example.com"}`
resp, err := http.Post(ts.URL+"/users", "application/json",
bytes.NewBufferString(body))
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var user User
json.NewDecoder(resp.Body).Decode(&user)
resp.Body.Close()
assert.NotZero(t, user.ID)
// 조회
getResp, _ := http.Get(fmt.Sprintf("%s/users/%d", ts.URL, user.ID))
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
})
}
핵심 정리
| 도구 | 용도 |
|---|---|
httptest.NewRecorder() | 핸들러 단위 테스트 |
httptest.NewRequest() | 테스트용 요청 생성 |
httptest.NewServer() | 전체 서버 통합 테스트 |
httptest.NewTLSServer() | HTTPS 통합 테스트 |
| testcontainers | 실제 DB/서비스 통합 테스트 |
- 단위 테스트:
httptest.Recorder+ Mock 서비스 - 통합 테스트:
httptest.Server+ 실제 서비스 - E2E 테스트:
httptest.Server+ testcontainers DB