09 de ago. de 2025·7 min de leitura

Testando handlers REST em Go: httptest e testes table-driven

Testar handlers REST em Go com httptest e casos table-driven oferece uma forma repetível de verificar auth, validação, códigos de status e casos de borda antes do lançamento.

Testando handlers REST em Go: httptest e testes table-driven

Sobre o que você deve estar confiante antes do lançamento

Um handler REST pode compilar, passar numa verificação manual rápida e ainda falhar em produção. A maioria das falhas não são problemas de sintaxe. São problemas de contrato: o handler aceita o que deveria rejeitar, retorna o código de status errado ou vaza detalhes em um erro.

Testes manuais ajudam, mas é fácil perder casos de borda e regressões. Você testa o caminho feliz, talvez um erro óbvio, e segue em frente. Então uma pequena mudança na validação ou no middleware quebra silenciosamente um comportamento que você supunha estável.

O objetivo dos testes de handlers é simples: tornar as promessas do handler repetíveis. Isso inclui regras de autenticação, validação de entrada, códigos de status previsíveis e corpos de erro nos quais os clientes possam confiar.

O pacote httptest do Go é uma ótima opção porque permite exercitar um handler diretamente sem iniciar um servidor real. Você cria uma requisição HTTP, passa para o handler e inspeciona o corpo, cabeçalhos e código de status da resposta. Os testes ficam rápidos, isolados e fáceis de rodar a cada commit.

Antes do lançamento, você deve saber (não supor) que:

  • O comportamento de autenticação é consistente para tokens ausentes, tokens inválidos e papéis errados.
  • As entradas são validadas: campos obrigatórios, tipos, intervalos e (se você aplicar) campos desconhecidos.
  • Os códigos de status batem com o contrato (por exemplo, 401 vs 403, 400 vs 422).
  • As respostas de erro são seguras e consistentes (sem rastros de pilha, mesma forma toda vez).
  • Caminhos não felizes são tratados: timeouts, falhas de dependência e resultados vazios.

Um endpoint “Criar ticket” pode funcionar quando você envia JSON perfeito como administrador. Os testes pegam o que você esqueceu de tentar: um token expirado, um campo extra que o cliente enviou por engano, uma prioridade negativa ou a diferença entre “não encontrado” e “erro interno” quando uma dependência falha.

Defina o contrato para cada endpoint

Escreva o que o handler promete fazer antes de escrever os testes. Um contrato claro mantém os testes focados e impede que virem suposições sobre o que o código “queria dizer”. Também torna as refatorações mais seguras, pois você pode alterar a implementação sem mudar o comportamento.

Comece pelas entradas. Seja específico sobre de onde vem cada valor e o que é obrigatório. Um endpoint pode receber um id no caminho, limit na query, um header Authorization e um corpo JSON. Anote as regras relevantes: formatos permitidos, valores mínimos/máximos, campos obrigatórios e o que acontece quando algo falta.

Depois defina as saídas. Não pare em “retorna JSON”. Decida como é o sucesso, quais headers importam e como são os erros. Se clientes dependem de códigos de erro estáveis e de uma forma JSON previsível, trate isso como parte do contrato.

Uma checklist prática é:

  • Entradas: valores de path/query, headers obrigatórios, campos JSON e regras de validação
  • Saídas: código de status, headers da resposta, formato JSON para sucesso e erro
  • Efeitos colaterais: que dados mudam e o que é criado
  • Dependências: chamadas ao banco, serviços externos, hora atual, IDs gerados

Também decida até onde vão os testes de handler. Eles são mais fortes na fronteira HTTP: autenticação, parsing, validação, códigos de status e corpos de erro. Empurre preocupações mais profundas para testes de integração: consultas reais ao banco, chamadas de rede e roteamento completo.

Se seu backend é gerado (por exemplo, AppMaster gera handlers e lógica de negócio em Go), uma abordagem contract-first é ainda mais útil. Você pode regenerar código e ainda verificar que cada endpoint mantém o mesmo comportamento público.

Configure um harness mínimo com httptest

Um bom teste de handler deve parecer que envia uma requisição real, sem iniciar um servidor. Em Go, isso geralmente significa: construir uma requisição com httptest.NewRequest, capturar a resposta com httptest.NewRecorder e chamar seu handler.

Chamar o handler diretamente dá testes rápidos e focados. Isso é ideal quando você quer validar comportamento dentro do handler: checagens de auth, regras de validação, códigos de status e corpos de erro. Usar um router nos testes é útil quando o contrato depende de params de caminho, correspondência de rotas ou ordem de middlewares. Comece com chamadas diretas e acrescente o router só quando precisar.

Headers importam mais do que a maioria pensa. Um Content-Type ausente pode mudar como o handler lê o corpo. Defina os headers que você espera em cada caso para que falhas apontem para a lógica, não para o setup do teste.

Aqui está um padrão mínimo que você pode reaproveitar:

req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()

Para manter asserções consistentes, ajuda ter um helper pequeno para ler e decodificar o corpo. Na maioria dos testes, verifique o código de status primeiro (para que falhas sejam fáceis de escanear), depois os headers principais que você promete (frequentemente Content-Type) e então o corpo.

Se seu backend é gerado (incluindo um backend Go produzido por AppMaster), esse harness ainda se aplica. Você está testando o contrato HTTP que os usuários dependem, não o estilo do código por trás.

Desenhe casos table-driven que permaneçam legíveis

Testes table-driven funcionam melhor quando cada caso lê como uma pequena história: a requisição que você envia e o que espera de volta. Você deve conseguir escanear a tabela e entender a cobertura sem pular pelo arquivo.

Um caso sólido normalmente tem: um nome claro, a requisição (método, caminho, headers, corpo), o status esperado e uma verificação da resposta. Para corpos JSON, prefira afirmar alguns campos estáveis (como um código de erro) em vez de casar toda a string JSON, a menos que seu contrato exija saída estrita.

Um formato simples de caso que você pode reutilizar

Mantenha o struct do caso focado. Coloque setups pontuais em helpers para que a tabela fique pequena.

type tc struct {
    name       string
    method     string
    path       string
    headers    map[string]string
    body       string
    wantStatus int
    wantBody   string // substring or compact JSON
}

Para diferentes entradas, use pequenas strings de corpo que mostrem a diferença num relance: um payload válido, um com campo faltando, um com tipo errado e uma string vazia. Evite construir JSON com muita formatação na tabela — isso fica barulhento rapidamente.

Quando você vir setup repetido (criação de token, headers comuns, body padrão), coloque em helpers como newRequest(tc) ou baseHeaders().

Se uma tabela começar a misturar muitas ideias, divida-a. Uma tabela para caminhos de sucesso e outra para caminhos de erro costuma ser mais fácil de ler e depurar.

Checagens de auth: os casos que normalmente são pulados

Conecte integrações comuns
Adicione pagamentos Stripe, mensagens ou integrações OpenAI e mantenha o comportamento da API consistente.
Explorar Integrações

Testes de auth normalmente parecem ok no caminho feliz e então falham em produção porque um caso “pequeno” nunca foi exercitado. Trate auth como um contrato: o que o cliente envia, o que o servidor retorna e o que nunca deve ser revelado.

Comece com presença e validade do token. Um endpoint protegido deve se comportar diferente quando o header está ausente vs presente mas errado. Se você usa tokens de curta duração, teste expiração também, mesmo que simule injetando um validador que retorna "expired".

A maioria das lacunas é coberta por esses casos:

  • Sem header Authorization -> 401 com uma resposta de erro estável
  • Header malformado (prefixo errado) -> 401
  • Token inválido (assinatura ruim) -> 401
  • Token expirado -> 401 (ou o código que você escolher) com mensagem previsível
  • Token válido mas papel/permissões erradas -> 403

A divisão 401 vs 403 importa. Use 401 quando o chamador não está autenticado. Use 403 quando está autenticado mas não autorizado. Se você confundir isso, clientes vão tentar novamente desnecessariamente ou mostrar a UI errada.

Checagens de propriedade também não bastam em endpoints “de propriedade do usuário” (como GET /orders/{id}). Teste propriedade: usuário A não deve ver o pedido do usuário B mesmo com token válido. Isso deve ser um 403 limpo (ou 404, se você preferir esconder existência), e o corpo não deve vazar nada. Mantenha o erro genérico. Não diga "pedido pertence ao usuário 42".

Regras de entrada: valide, rejeite e explique claramente

Muitos bugs pré-lançamento são de entrada: campos faltantes, tipos errados, formatos inesperados ou payloads grandes demais.

Nomeie cada entrada que o handler aceita: campos do corpo JSON, params de query e path. Para cada um, decida o que acontece quando falta, está vazio, malformado ou fora do intervalo. Depois escreva casos que provem que o handler rejeita entrada ruim cedo e sempre retorna o mesmo tipo de erro.

Um pequeno conjunto de casos de validação costuma cobrir a maior parte do risco:

  • Campos obrigatórios: ausente vs string vazia vs null (se aceitar null)
  • Tipos e formatos: número vs string, formatos email/data/UUID, parsing booleano
  • Limites de tamanho: max length, max items, payload muito grande
  • Campos desconhecidos: ignorados vs rejeitados (se você fizer decodificação estrita)
  • Query e path params: ausente, não parseável e comportamento padrão

Exemplo: um handler POST /users aceita { "email": "...", "age": 0 }. Teste email ausente, email como 123, email como "not-an-email", age como -1 e age como "20". Se você exige JSON estrito, também teste { "email":"[email protected]", "extra":"x" } e confirme que falha.

Faça falhas de validação previsíveis. Escolha um código de status para erros de validação (algumas equipes usam 400, outras 422) e mantenha a forma do corpo de erro consistente. Os testes devem afirmar tanto o status quanto uma mensagem (ou campo details) que aponte para a entrada exata que falhou.

Códigos de status e corpos de erro: torne-os previsíveis

Do backend aos apps
Construa apps web e mobile nativos no mesmo backend com um fluxo no-code.
Criar Apps

Testes de handler ficam mais fáceis quando falhas de API são enfadonhas e consistentes. Você quer que todo erro mapeie para um código claro e retorne a mesma forma JSON, independentemente de quem escreveu o handler.

Comece com um pequeno mapeamento acordado de tipos de erro para códigos HTTP:

  • 400 Bad Request: JSON malformado, query params obrigatórios faltando
  • 404 Not Found: ID do recurso não existe
  • 409 Conflict: violação de unicidade ou conflito de estado
  • 422 Unprocessable Entity: JSON válido mas viola regras de negócio
  • 500 Internal Server Error: falhas inesperadas (DB down, nil pointer, terceiro fora)

Então mantenha o corpo de erro estável. Mesmo que o texto da mensagem mude, os clientes devem ter campos previsíveis para depender:

{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }

Nos testes, afirme o formato, não apenas a linha de status. Uma falha comum é retornar HTML, texto simples ou corpo vazio em erros, o que quebra clientes e esconde bugs.

Teste também headers e codificação em respostas de erro:

  • Content-Type é application/json (e charset é consistente se você o definir)
  • Corpo é JSON válido mesmo em falhas
  • code, message e details existem (details pode estar vazio, mas não aleatório)
  • Panics e erros inesperados retornam um 500 seguro sem vazar stack traces

Se você adicionar um middleware de recover, inclua um teste que force um panic e confirme que você ainda recebe uma resposta JSON limpa de erro.

Casos de borda: falhas, tempo e caminhos não felizes

Crie ferramentas internas rapidamente
Crie painéis administrativos e portais suportados por APIs que você pode testar com confiança antes do lançamento.
Começar a Construir

Testes do caminho feliz provam que o handler funciona uma vez. Testes de casos de borda provam que ele continua funcionando quando o mundo está bagunçado.

Force dependências a falhar de formas específicas e repetíveis. Se o handler chama banco, cache ou API externa, você quer ver o que acontece quando essas camadas retornam erros que você não controla.

Vale a pena simular pelo menos uma vez por endpoint:

  • Timeout de uma chamada downstream (context deadline exceeded)
  • Not found do storage quando o cliente esperava dados
  • Violação de constraint única ao criar (email duplicado, slug duplicado)
  • Erro de rede/transport (connection refused, broken pipe)
  • Erro interno inesperado ("algo deu errado" genérico)

Mantenha testes estáveis controlando tudo que pode variar entre execuções. Um teste flaky é pior que nenhum, pois treina pessoas a ignorar falhas.

Torne tempo e aleatoriedade previsíveis

Se o handler usa time.Now(), IDs ou valores randômicos, injetáveis. Passe uma função clock e um gerador de IDs para o handler ou serviço. Nos testes, retorne valores fixos para que você possa afirmar campos JSON e headers exatos.

Use fakes pequenos e afirme “sem efeitos colaterais”

Prefira fakes ou stubs pequenos a mocks completos. Um fake pode registrar chamadas e permitir que você verifique que nada aconteceu após uma falha.

Por exemplo, em um handler “create user”, se o insert no banco falhar por violação de unicidade, afirme o código de status, o corpo de erro estável e que nenhum email de boas-vindas foi enviado. Seu fake de mailer pode expor um contador (sent=0) para provar que o caminho de falha não acionou efeitos colaterais.

Erros comuns que tornam testes de handler pouco confiáveis

Testes de handler frequentemente falham pelo motivo errado. A requisição que você monta no teste não tem a mesma forma que uma requisição real. Isso leva a falhas ruidosas e confiança falsa.

Um problema comum é enviar JSON sem os headers que o handler espera. Se seu código verifica Content-Type: application/json, esquecer esse header pode fazer o handler pular o decode do JSON, retornar um código diferente ou seguir um ramo que nunca ocorre em produção. O mesmo vale para auth: um header Authorization ausente não é o mesmo que um token inválido. Esses devem ser casos diferentes.

Outra armadilha é afirmar todo o JSON de resposta como string bruta. Pequenas mudanças como ordem de campos, espaçamento ou novos campos quebram testes mesmo quando a API está correta. Decode o corpo em uma struct ou map[string]any, então afirme o que importa: status, código de erro, mensagem e alguns campos-chave.

Testes também ficam pouco confiáveis quando casos compartilham estado mutável. Reutilizar o mesmo store em memória, variáveis globais ou um router singleton entre linhas da tabela pode vazar dados entre casos. Cada caso de teste deve começar limpo, ou resetar o estado em t.Cleanup.

Padrões que geralmente causam testes frágeis:

  • Montar requisições sem os mesmos headers e codificação que clientes reais usam
  • Afirmar strings JSON completas em vez de decodificar e checar campos
  • Reusar estado compartilhado (banco/cache/handler global) entre casos
  • Empacotar auth, validação e lógica de negócio em um único teste gigantesco

Mantenha cada teste focado. Se um caso falha, você deve saber se foi auth, regras de input ou formatação de erro em segundos.

Uma checklist rápida pré-lançamento que você pode reaproveitar

Regenerar sem dívida técnica
Atualize requisitos e regenere código-fonte limpo em vez de corrigir handlers antigos.
Gerar Código

Antes de enviar, os testes devem provar duas coisas: o endpoint segue seu contrato e falha de formas seguras e previsíveis.

Rode isso como casos table-driven e faça cada caso afirmar tanto a resposta quanto quaisquer efeitos colaterais:

  • Auth: sem token, token ruim, papel errado, papel correto (e confirme que o case “papel errado” não vaza detalhes)
  • Entradas: campos obrigatórios faltando, tipos errados, limites (min/max), campos desconhecidos que você quer rejeitar
  • Saídas: código de status, headers chave (como Content-Type), campos JSON requeridos, forma de erro consistente
  • Dependências: force uma falha downstream (DB, fila, pagamento, email), verifique mensagem segura, confirme que não houve escrita parcial
  • Idempotência: repita a mesma requisição (ou retry após timeout) e confirme que não cria duplicados

Depois disso, adicione uma asserção de sanidade que normalmente é pulada: confirme que o handler não tocou onde não devia. Por exemplo, em um caso de validação falha, verifique que nenhum registro foi criado e nenhum email foi enviado.

Se você constrói APIs com uma ferramenta como AppMaster, esse mesmo checklist ainda vale. O ponto é o mesmo: provar que o comportamento público permanece estável.

Exemplo: um endpoint, uma tabela pequena e o que isso pega

Suponha um endpoint simples: POST /login. Ele aceita JSON com email e password. Retorna 200 com um token no sucesso, 400 para input inválido, 401 para credenciais erradas e 500 se o serviço de auth estiver fora.

Uma tabela compacta como esta cobre a maior parte do que quebra em produção.

func TestLoginHandler(t *testing.T) {
    // Fake dependency so we can force 200/401/500 without hitting real systems.
    auth := &FakeAuth{ /* configure per test */ }
    h := NewLoginHandler(auth)

    tests := []struct {
        name       string
        body       string
        authHeader string
        setup      func()
        wantStatus int
        wantBody   string
    }{
        {"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
        {"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
        {"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
        {"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
        {"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
        {"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            tt.setup()
            req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
            req.Header.Set("Content-Type", "application/json")
            if tt.authHeader != "" {
                req.Header.Set("Authorization", tt.authHeader)
            }

            rr := httptest.NewRecorder()
            h.ServeHTTP(rr, req)

            if rr.Code != tt.wantStatus {
                t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
            }
            if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
                t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
            }
        })
    }
}

Percorra um caso de ponta a ponta: para “missing password”, você envia um corpo com apenas email, define Content-Type, executa via ServeHTTP e então afirma 400 e um erro que aponte claramente para password. Esse único caso prova que seu decoder, validador e formato de erro funcionam juntos.

Se você quer uma forma mais rápida de padronizar contratos, módulos de auth e integrações enquanto ainda entrega código Go real, AppMaster (appmaster.io) foi feito para isso. Mesmo assim, esses testes continuam valiosos porque fixam o comportamento do qual seus clientes dependem.

Fácil de começar
Criar algo espantoso

Experimente o AppMaster com plano gratuito.
Quando estiver pronto, você poderá escolher a assinatura adequada.

Comece