HTTP Handler Testing — Integration Testing with httptest
Go's standard library net/http/httptest package lets you fully test HTTP handlers without starting a real server.
httptest.Recorder — Unit Testing Handlers
// 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, "invalid ID", http.StatusBadRequest)
return
}
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "user not found", 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, "invalid request body", 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)
}
Testing Handlers with 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: "find existing user",
id: "1",
mockUser: &User{ID: 1, Name: "Kim Golang", Email: "golang@example.com"},
wantStatus: http.StatusOK,
},
{
name: "user not found",
id: "999",
mockUser: nil,
wantStatus: http.StatusNotFound,
},
{
name: "invalid ID",
id: "abc",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock service
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)
// Create request
req := httptest.NewRequest(http.MethodGet, "/users/"+tt.id, nil)
req.SetPathValue("id", tt.id) // Go 1.22+
// Record response
w := httptest.NewRecorder()
// Call handler
h.GetUser(w, req)
// Verify
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("create user successfully", func(t *testing.T) {
mockSvc := new(MockUserService)
mockSvc.On("CreateUser", mock.Anything, "Kim Golang", "golang@example.com").
Return(&User{ID: 1, Name: "Kim Golang", Email: "golang@example.com"}, nil)
h := NewUserHandler(mockSvc)
body := `{"name":"Kim Golang","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("invalid JSON body", 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 — Full Server Integration Testing
Start a real temporary HTTP server to test both handler and client together.
func TestServerIntegration(t *testing.T) {
// Setup test router
mux := http.NewServeMux()
svc := &realUserService{db: setupTestDB(t)}
h := NewUserHandler(svc)
mux.HandleFunc("GET /users/{id}", h.GetUser)
mux.HandleFunc("POST /users", h.CreateUser)
// Create temporary test server (random port)
ts := httptest.NewServer(mux)
defer ts.Close()
client := ts.Client() // HTTP client for communicating with test server
t.Run("create user then retrieve", func(t *testing.T) {
// Create
body := `{"name":"integration test","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()
// Retrieve
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, "integration test", found.Name)
})
}
Testing Gin/Echo Handlers
Gin Testing
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
}{
{"existing user", "GET", "/users/1", "", http.StatusOK},
{"user not found", "GET", "/users/999", "", http.StatusNotFound},
{"create user", "POST", "/users", `{"name":"test","email":"t@test.com"}`, http.StatusCreated},
}
// Setup mocks
mockSvc.On("GetUser", mock.Anything, int64(1)).
Return(&User{ID: 1, Name: "test"}, nil)
mockSvc.On("GetUser", mock.Anything, int64(999)).
Return(nil, nil)
mockSvc.On("CreateUser", mock.Anything, "test", "t@test.com").
Return(&User{ID: 1, Name: "test"}, 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)
})
}
}
Middleware Testing
func TestAuthMiddleware(t *testing.T) {
t.Run("valid token", 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("no token", func(t *testing.T) {
handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
Database Integration Testing with testcontainers
Complete integration test using a real PostgreSQL container.
func TestUserHandlerWithRealDB(t *testing.T) {
if testing.Short() {
t.Skip("integration test: skipped with -short flag")
}
ctx := context.Background()
// Start PostgreSQL container
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)
// Connect to database and run migrations
dsn, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)
runMigrations(t, db)
// Setup real service & handler
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 scenario
t.Run("E2E: create then retrieve", func(t *testing.T) {
// Create
body := `{"name":"E2E test","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)
// Retrieve
getResp, _ := http.Get(fmt.Sprintf("%s/users/%d", ts.URL, user.ID))
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
})
}
Key Summary
| Tool | Purpose |
|---|---|
httptest.NewRecorder() | Unit test handlers |
httptest.NewRequest() | Create test requests |
httptest.NewServer() | Full server integration testing |
httptest.NewTLSServer() | HTTPS integration testing |
| testcontainers | Real DB/service integration testing |
- Unit tests:
httptest.Recorder+ Mock services - Integration tests:
httptest.Server+ real services - E2E tests:
httptest.Server+ testcontainers database