Testing de HTTP handlers com httptest
1. Introdução ao pacote net/http/httptest
O pacote net/http/httptest é uma ferramenta essencial do ecossistema Go para testar handlers HTTP sem a necessidade de iniciar um servidor real. Ele fornece utilitários que permitem simular requisições HTTP e capturar respostas de forma eficiente, tornando os testes rápidos, determinísticos e isolados.
Diferente de testes de integração que exigem um servidor em execução, os testes com httptest são verdadeiros testes unitários para handlers. Você testa a lógica do handler isoladamente, sem dependências de rede ou infraestrutura.
Os principais tipos oferecidos são:
- ResponseRecorder — captura a resposta escrita por um handler
- Server — cria um servidor HTTP de teste real
- NewRequest — constrói requisições HTTP simuladas
2. Configurando o ambiente de teste com httptest.NewRecorder
O ResponseRecorder é o componente central para testes unitários de handlers. Ele implementa http.ResponseWriter e permite inspecionar o que foi escrito durante a execução do handler.
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func TestHealthHandler(t *testing.T) {
// Cria um ResponseRecorder para capturar a resposta
recorder := httptest.NewRecorder()
// Cria uma requisição simulada
req := httptest.NewRequest(http.MethodGet, "/health", nil)
// Executa o handler manualmente
healthHandler(recorder, req)
// Verifica o status code
if recorder.Code != http.StatusOK {
t.Errorf("esperado status %d, obtido %d", http.StatusOK, recorder.Code)
}
// Verifica o body
expected := `{"status": "ok"}`
if recorder.Body.String() != expected {
t.Errorf("esperado body %s, obtido %s", expected, recorder.Body.String())
}
}
3. Testando diferentes métodos HTTP e cenários
Handlers frequentemente precisam responder a diferentes métodos HTTP e processar parâmetros variados. Vamos testar um handler que gerencia usuários:
func userHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
name := r.URL.Query().Get("name")
if name == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name": "` + name + `"}`))
case http.MethodPost:
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
w.WriteHeader(http.StatusUnsupportedMediaType)
return
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"created": true}`))
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func TestUserHandler_GetWithQuery(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/user?name=Alice", nil)
userHandler(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("esperado 200, obtido %d", recorder.Code)
}
}
func TestUserHandler_PostWithJSON(t *testing.T) {
recorder := httptest.NewRecorder()
body := `{"email": "alice@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/user", nil)
req.Header.Set("Content-Type", "application/json")
userHandler(recorder, req)
if recorder.Code != http.StatusCreated {
t.Errorf("esperado 201, obtido %d", recorder.Code)
}
}
func TestUserHandler_InvalidMethod(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/user", nil)
userHandler(recorder, req)
if recorder.Code != http.StatusMethodNotAllowed {
t.Errorf("esperado 405, obtido %d", recorder.Code)
}
}
4. Verificando respostas completas do handler
Para respostas JSON, é comum desserializar o body em structs para validação mais robusta. A biblioteca testify oferece asserções mais expressivas:
import (
"encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type UserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func TestUserHandler_ResponseValidation(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/user?id=1", nil)
userHandler(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
var response UserResponse
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, 1, response.ID)
assert.NotEmpty(t, response.Name)
assert.Contains(t, response.Email, "@")
}
5. Testando middlewares e cadeias de handlers
Middlewares modificam o fluxo de requisições. Testar handlers com middlewares requer empacotamento adequado:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simula logging
w.Header().Set("X-Request-ID", "test-123")
next.ServeHTTP(w, r)
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "valid-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func TestHandlerWithMiddleware(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Empacota com middlewares
wrappedHandler := authMiddleware(loggingMiddleware(handler))
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "valid-token")
wrappedHandler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "test-123", recorder.Header().Get("X-Request-ID"))
}
func TestMiddlewareBlocksUnauthorized(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrappedHandler := authMiddleware(handler)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
// Sem token de autorização
wrappedHandler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
}
6. Usando httptest.NewServer para testes de integração
Para testes que exigem um servidor HTTP real (como testar clientes HTTP), httptest.NewServer cria um servidor em uma porta aleatória:
func TestIntegrationWithServer(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "hello"}`))
})
server := httptest.NewServer(handler)
defer server.Close()
// Faz uma requisição HTTP real ao servidor de teste
resp, err := http.Get(server.URL + "/api")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]string
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "hello", body["message"])
}
7. Testando handlers com dependências externas (mock)
Handlers frequentemente dependem de serviços externos. Usando interfaces e mocks, podemos testar isoladamente:
type UserRepository interface {
Save(user User) error
FindByID(id int) (*User, error)
}
type createUserHandler struct {
repo UserRepository
}
func (h *createUserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := h.repo.Save(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// Mock usando testify
type mockRepository struct {
mock.Mock
}
func (m *mockRepository) Save(user User) error {
args := m.Called(user)
return args.Error(0)
}
func TestCreateUserHandler(t *testing.T) {
mockRepo := new(mockRepository)
mockRepo.On("Save", mock.AnythingOfType("User")).Return(nil)
handler := &createUserHandler{repo: mockRepo}
recorder := httptest.NewRecorder()
body := `{"name": "Bob", "email": "bob@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/users",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusCreated, recorder.Code)
mockRepo.AssertExpectations(t)
}
8. Boas práticas e armadilhas comuns
Table-driven tests são ideais para testar múltiplos cenários:
func TestUserHandlerTableDriven(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
headers map[string]string
wantStatus int
}{
{"GET sem nome", http.MethodGet, "/user", "", nil, http.StatusBadRequest},
{"GET com nome", http.MethodGet, "/user?name=Alice", "", nil, http.StatusOK},
{"POST sem header", http.MethodPost, "/user", `{"email":"a@b.com"}`, nil, http.StatusUnsupportedMediaType},
{"POST válido", http.MethodPost, "/user", `{"email":"a@b.com"}`,
map[string]string{"Content-Type": "application/json"}, http.StatusCreated},
{"DELETE não permitido", http.MethodDelete, "/user", "", nil, http.StatusMethodNotAllowed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path,
strings.NewReader(tt.body))
for k, v := range tt.headers {
req.Header.Set(k, v)
}
userHandler(recorder, req)
assert.Equal(t, tt.wantStatus, recorder.Code)
})
}
}
Armadilhas comuns a evitar:
- Não usar defer server.Close() em servidores de teste
- Compartilhar estado global entre testes paralelos
- Ignorar validação de body vazio ou malformado
- Testar apenas o caminho feliz (happy path)
Referências
- Documentação oficial do pacote httptest — Referência completa da API com exemplos de ResponseRecorder, NewRequest e NewServer
- Testing HTTP handlers in Go (The Go Blog) — Artigo oficial da equipe Go sobre boas práticas de teste com httptest
- Testify - assert e mock — Biblioteca popular para asserções e mocks em testes Go, amplamente usada com httptest
- How to test HTTP handlers in Go (Alex Edwards) — Tutorial prático com exemplos de table-driven tests e middlewares
- Testing HTTP handlers with httptest (DigitalOcean) — Guia completo com cenários de GET, POST, JSON e formulários