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