Skip to main content

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

ToolPurpose
httptest.NewRecorder()Unit test handlers
httptest.NewRequest()Create test requests
httptest.NewServer()Full server integration testing
httptest.NewTLSServer()HTTPS integration testing
testcontainersReal DB/service integration testing
  • Unit tests: httptest.Recorder + Mock services
  • Integration tests: httptest.Server + real services
  • E2E tests: httptest.Server + testcontainers database