Probar manejadores REST en Go: httptest y pruebas dirigidas por tablas
Probar manejadores REST en Go con httptest y casos dirigidos por tablas te ofrece una forma repetible de verificar auth, validación, códigos de estado y casos límite antes del lanzamiento.

Qué debes tener claro antes del lanzamiento
Un manejador REST puede compilar, pasar una comprobación manual rápida y aun así fallar en producción. La mayoría de las fallas no son problemas de sintaxis. Son problemas de contrato: el manejador acepta lo que debería rechazar, devuelve el código de estado incorrecto o filtra detalles en un error.
Las pruebas manuales ayudan, pero es fácil pasar por alto casos límite y regresiones. Pruebas la ruta feliz, quizá un error obvio, y sigues. Luego un pequeño cambio en la validación o en el middleware rompe silenciosamente un comportamiento que dabas por sentado.
El objetivo de las pruebas de handlers es simple: que las promesas del manejador sean repetibles. Eso incluye reglas de autenticación, validación de entrada, códigos de estado previsibles y cuerpos de error en los que los clientes puedan confiar.
El paquete httptest de Go encaja muy bien porque puedes ejecutar un handler directamente sin arrancar un servidor real. Construyes una petición HTTP, la pasas al handler e inspeccionas el cuerpo de la respuesta, cabeceras y código de estado. Las pruebas son rápidas, aisladas y fáciles de ejecutar en cada commit.
Antes de lanzar, deberías saber (no esperar) que:
- El comportamiento de autenticación es consistente para tokens faltantes, tokens inválidos y roles incorrectos.
- Las entradas se validan: campos obligatorios, tipos, rangos y (si lo exiges) campos desconocidos.
- Los códigos de estado coinciden con el contrato (por ejemplo, 401 vs 403, 400 vs 422).
- Las respuestas de error son seguras y consistentes (sin stack traces, misma forma siempre).
- Las rutas no felices están manejadas: timeouts, fallos en dependencias y resultados vacíos.
Un endpoint “Crear ticket” podría funcionar cuando envías JSON perfecto como admin. Las pruebas detectan lo que olvidas probar: un token caducado, un campo extra que el cliente envía por accidente, una prioridad negativa o la diferencia entre “no encontrado” y “error interno” cuando una dependencia falla.
Define el contrato para cada endpoint
Escribe lo que el handler promete hacer antes de escribir las pruebas. Un contrato claro mantiene las pruebas enfocadas y evita que se conviertan en adivinanzas sobre lo que el código “quería” hacer. También hace los refactors más seguros porque puedes cambiar internals sin alterar el comportamiento público.
Empieza por las entradas. Sé específico sobre de dónde viene cada valor y qué es requerido. Un endpoint puede tomar un id de la ruta, limit de la query, un header Authorization y un body JSON. Anota las reglas que importan: formatos permitidos, valores min/max, campos obligatorios y qué ocurre cuando falta algo.
Luego define las salidas. No te quedes en “devuelve JSON”. Decide qué significa éxito, qué cabeceras importan y cómo son los errores. Si los clientes dependen de códigos de error estables y una forma JSON predecible, trátalo como parte del contrato.
Una lista práctica:
- Entradas: valores de path/query, headers requeridos, campos JSON y reglas de validación
- Salidas: código de estado, cabeceras de respuesta, forma JSON para éxito y error
- Efectos secundarios: qué datos cambian y qué se crea
- Dependencias: llamadas a BD, servicios externos, tiempo actual, IDs generados
También decide dónde terminan las pruebas de handler. Las pruebas de handler son más fuertes en el límite HTTP: auth, parsing, validación, códigos de estado y cuerpos de error. Empuja preocupaciones más profundas a tests de integración: consultas reales a BD, llamadas de red y routing completo.
Si tu backend se genera (por ejemplo, AppMaster produce handlers y lógica de negocio en Go), un enfoque contract-first es aún más útil. Puedes regenerar código y verificar que cada endpoint mantiene el mismo comportamiento público.
Configura un harness mínimo con httptest
Una buena prueba de handler debe sentirse como enviar una petición real, sin arrancar un servidor. En Go, eso suele significar: construir una request con httptest.NewRequest, capturar la respuesta con httptest.NewRecorder y llamar a tu handler.
Llamar al handler directamente da pruebas rápidas y enfocadas. Esto es ideal cuando quieres validar comportamiento dentro del handler: checks de auth, reglas de validación, códigos de estado y cuerpos de error. Usar un router en las pruebas es útil cuando el contrato depende de params en la ruta, matching de rutas o el orden de middleware. Empieza llamando directo y añade el router solo cuando lo necesites.
Los headers importan más de lo que la mayoría piensa. Un Content-Type faltante puede cambiar cómo el handler lee el body. Establece los headers que esperas en cada caso para que los fallos apunten a la lógica, no a la configuración de la prueba.
Aquí tienes un patrón mínimo reutilizable:
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 mantener las aserciones consistentes, ayuda tener un pequeño helper para leer y decodificar el cuerpo de la respuesta. En la mayoría de pruebas, comprueba el código de estado primero (para que los fallos sean fáciles de escanear), luego las cabeceras clave que prometes (a menudo Content-Type) y luego el body.
Si tu backend se genera (incluyendo un backend Go producido por AppMaster), este harness sigue aplicando. Estás probando el contrato HTTP del que dependen los usuarios, no el estilo del código detrás.
Diseña casos table-driven que sigan siendo legibles
Las pruebas dirigidas por tablas funcionan mejor cuando cada caso se lee como una pequeña historia: la petición que envías y lo que esperas recibir. Debes poder escanear la tabla y entender la cobertura sin saltar por el archivo.
Un caso sólido suele tener: un nombre claro, la request (método, ruta, headers, body), el código de estado esperado y una comprobación de la respuesta. Para cuerpos JSON, prefiere afirmar unos pocos campos estables (como un código de error) en lugar de comparar toda la cadena JSON, a menos que tu contrato exija salida estricta.
Una forma simple de case que puedes reutilizar
Mantén el struct del case enfocado. Pon la configuración ad-hoc en helpers para que la tabla se mantenga pequeña.
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, usa cadenas de body pequeñas que muestren la diferencia a simple vista: un payload válido, uno con un campo faltante, uno con tipo incorrecto y uno con cadena vacía. Evita construir JSON con mucho formateo en la tabla: se vuelve ruidoso rápido.
Cuando veas setup repetido (creación de token, headers comunes, body por defecto), muévelo a helpers como newRequest(tc) o baseHeaders().
Si una tabla empieza a mezclar demasiadas ideas, sepárala. Una tabla para rutas de éxito y otra para rutas de error suele ser más fácil de leer y depurar.
Checks de autenticación: los casos que suelen omitirse
Las pruebas de auth a menudo parecen correctas en la ruta feliz y luego fallan en producción porque un caso “pequeño” nunca se ejercitó. Trata la auth como un contrato: qué envía el cliente, qué devuelve el servidor y qué nunca debe revelarse.
Empieza por presencia y validez del token. Un endpoint protegido debe comportarse distinto cuando falta el header frente a cuando está pero es incorrecto. Si usas tokens de corta duración, prueba la expiración también, aunque la simules inyectando un validador que devuelva “expired”.
La mayoría de las brechas se cubren con estos casos:
- Sin header
Authorization-> 401 con una respuesta de error estable - Header malformado (prefijo incorrecto) -> 401
- Token inválido (firma mala) -> 401
- Token expirado -> 401 (o el código que elijas) con un mensaje predecible
- Token válido pero rol/permisos incorrectos -> 403
La distinción 401 vs 403 importa. Usa 401 cuando el llamador no está autenticado. Usa 403 cuando está autenticado pero no tiene permiso. Si las mezclas, los clientes reintentarán innecesariamente o mostrarán la UI incorrecta.
Los checks de rol tampoco bastan en endpoints “propiedad del usuario” (como GET /orders/{id}). Prueba la propiedad: el usuario A no debería ver la orden del usuario B aunque tenga un token válido. Eso debe ser un 403 limpio (o 404, si escondes la existencia) y el cuerpo no debe filtrar nada. Mantén el error genérico. No sugieras que “la orden pertenece al usuario 42”.
Reglas de entrada: valida, rechaza y explica con claridad
Muchos bugs pre-lanzamiento son por entradas: campos faltantes, tipos erróneos, formatos inesperados o payloads demasiado grandes.
Nombra cada entrada que acepta tu handler: campos del body JSON, params de query y params de path. Para cada uno, decide qué pasa cuando falta, está vacío, malformado o fuera de rango. Luego escribe casos que prueben que el handler rechaza la entrada mala temprano y devuelve siempre el mismo tipo de error.
Un pequeño conjunto de casos de validación cubre la mayoría del riesgo:
- Campos requeridos: faltantes vs cadena vacía vs null (si permites null)
- Tipos y formatos: número vs string, formatos email/fecha/UUID, parseo booleano
- Límites de tamaño: longitud máxima, items máximos, payload demasiado grande
- Campos desconocidos: ignorados vs rechazados (si aplicas decodificación estricta)
- Query y path params: faltantes, no parseables y comportamiento por defecto
Ejemplo: un handler POST /users acepta { "email": "...", "age": 0 }. Prueba email faltante, email como 123, email como "not-an-email", age como -1 y age como "20". Si requieres JSON estricto, también prueba { "email":"[email protected]", "extra":"x" } y confirma que falla.
Haz que los fallos de validación sean predecibles. Escoge un código de estado para errores de validación (algunos equipos usan 400, otros 422) y mantén la forma del cuerpo de error consistente. Las pruebas deben afirmar tanto el estado como un mensaje (o un campo details) que apunte al input exacto que falló.
Códigos de estado y cuerpos de error: hazlos predecibles
Las pruebas de handlers son más sencillas cuando los fallos de la API son aburridos y consistentes. Quieres que cada error mapee a un código de estado claro y devuelva la misma forma JSON, sin importar quién escribió el handler.
Empieza con un mapeo pequeño y acordado de tipos de error a códigos HTTP:
- 400 Bad Request: JSON malformado, params de query requeridos que faltan
- 404 Not Found: el recurso con ese ID no existe
- 409 Conflict: violación de unicidad o conflicto de estado
- 422 Unprocessable Entity: JSON válido pero falla reglas de negocio
- 500 Internal Server Error: fallos inesperados (BD caída, nil pointer, fallo de tercero)
Luego mantiene el cuerpo de error estable. Aunque el texto del mensaje cambie, los clientes deberían tener campos predecibles en los que confiar:
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
En las pruebas, afirma la forma, no solo la línea de estado. Un fallo común es devolver HTML, texto plano o un cuerpo vacío en errores, lo que rompe clientes y oculta bugs.
También prueba cabeceras y codificación para respuestas de error:
Content-Typeesapplication/json(y el charset es consistente si lo fijas)- El cuerpo es JSON válido incluso en errores
code,messageydetailsexisten (details puede estar vacío, pero no debe ser aleatorio)- Panics y errores inesperados devuelven un 500 seguro sin filtrar stack traces
Si añades un middleware de recover, incluye una prueba que fuerce un panic y confirme que aún obtienes una respuesta JSON limpia.
Casos límite: fallos, tiempo y rutas no felices
Las pruebas de ruta feliz demuestran que el handler funciona una vez. Las de casos límite prueban que mantiene el comportamiento cuando el mundo está desordenado.
Forza a las dependencias a fallar de formas específicas y repetibles. Si tu handler llama a una BD, cache o API externa, quieres ver qué ocurre cuando esas capas devuelven errores fuera de tu control.
Vale la pena simular al menos una vez por endpoint:
- Timeout desde una llamada descendente (
context deadline exceeded) - Not found desde el almacenamiento cuando el cliente esperaba datos
- Violación de unicidad al crear (email duplicado, slug duplicado)
- Error de red o transporte (connection refused, broken pipe)
- Error interno inesperado (“algo salió mal” genérico)
Mantén las pruebas estables controlando todo lo que pueda variar entre ejecuciones. Una prueba flaky es peor que ninguna porque hace que la gente ignore los fallos.
Haz el tiempo y la aleatoriedad previsibles
Si el handler usa time.Now(), IDs o valores aleatorios, inyéctalos. Pasa una función clock y un generador de IDs al handler o servicio. En las pruebas, devuelve valores fijos para poder afirmar campos JSON y cabeceras exactas.
Usa fakes pequeños y afirma “sin efectos secundarios”
Prefiere fakes o stubs pequeños sobre mocks completos. Un fake puede registrar llamadas y permitirte afirmar que no ocurrió nada después de una falla.
Por ejemplo, en un handler “crear usuario”, si el insert a la BD falla por una violación de unicidad, afirma que el código de estado es correcto, que el cuerpo de error es estable y que no se envió el email de bienvenida. Tu fake de mailer puede exponer un contador (sent=0) para que la ruta de fallo pruebe que no se dispararon efectos secundarios.
Errores comunes que hacen que las pruebas de handler sean poco fiables
Las pruebas de handler suelen fallar por la razón equivocada. La request que construyes en una prueba no tiene la misma forma que la petición de un cliente real. Eso lleva a fallos ruidosos y confianza falsa.
Un problema común es enviar JSON sin los headers que el handler espera. Si tu código comprueba Content-Type: application/json, olvidarlo puede hacer que el handler salte el decodificador JSON, devuelva otro código de estado o tome una rama que nunca ocurre en producción. Lo mismo pasa con auth: un header Authorization faltante no es lo mismo que un token inválido. Deben ser casos diferentes.
Otra trampa es afirmar todo el JSON de respuesta como una cadena cruda. Pequeños cambios como el orden de campos, espacios o nuevos campos rompen las pruebas aun cuando la API está correcta. Decodifica el body en un struct o map[string]any y luego afirma lo que importa: estado, código de error, mensaje y un par de campos clave.
Las pruebas también se vuelven no fiables cuando los casos comparten estado mutable. Reusar la misma tienda en memoria, variables globales o un router singleton entre filas de la tabla puede filtrar datos entre casos. Cada caso de prueba debe empezar limpio o resetear el estado en t.Cleanup.
Patrones que suelen causar tests frágiles:
- Construir requests sin los mismos headers y codificación que usan los clientes reales
- Afirmar JSON completo como strings en lugar de decodificar y verificar campos
- Reusar estado compartido de BD/cache/handler entre casos
- Meter auth, validación y lógica de negocio en una sola prueba gigante
Mantén cada prueba enfocada. Si un caso falla, deberías saber si fue auth, reglas de entrada o formato de error en segundos.
Checklist rápido pre-lanzamiento que puedes reutilizar
Antes de enviar, las pruebas deben demostrar dos cosas: el endpoint cumple su contrato y falla de formas seguras y predecibles.
Ejecuta estos como casos dirigidos por tablas y haz que cada caso afirme la respuesta y cualquier efecto secundario:
- Auth: sin token, token malo, rol incorrecto, rol correcto (y confirma que el caso “rol incorrecto” no filtra detalles)
- Entradas: campos requeridos faltantes, tipos erróneos, tamaños límites (min/max), campos desconocidos que quieras rechazar
- Salidas: código de estado, cabeceras clave (como
Content-Type), campos JSON requeridos, forma de error consistente - Dependencias: fuerza un fallo en una dependencia (BD, queue, pago, email), verifica un mensaje seguro, confirma que no hay escrituras parciales
- Idempotencia: repite la misma petición (o reintenta tras un timeout) y confirma que no se crean duplicados
Después de eso, añade una aserción de sanidad que normalmente se salta: confirma que el handler no tocó lo que no debía. Por ejemplo, en un caso de validación fallida, verifica que no se creó ningún registro y que no se envió correo.
Si construyes APIs con una herramienta como AppMaster, esta misma checklist sigue aplicando. El punto es el mismo: asegura que el comportamiento público se mantiene estable.
Ejemplo: un endpoint, una tabla pequeña y lo que captura
Imagina un endpoint simple: POST /login. Acepta JSON con email y password. Devuelve 200 con un token al tener éxito, 400 para input inválido, 401 para credenciales incorrectas y 500 si el servicio de auth está caído.
Una tabla compacta como esta cubre la mayoría de lo que rompe en producción.
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)
}
})
}
}
Revisa un caso de principio a fin: para “missing password”, envías un body con solo email, pones Content-Type, lo ejecutas con ServeHTTP, luego afirmas 400 y un error que claramente apunte a password. Ese único caso prueba que tu decoder, validador y formato de error trabajan juntos.
Si quieres una forma más rápida de estandarizar contratos, módulos de auth e integraciones mientras sigues generando código Go real, AppMaster (appmaster.io) está pensado para eso. Aun así, estas pruebas siguen siendo valiosas porque fijan el comportamiento del que dependen tus clientes.


