Testeo HTTP: ResponseRecorder vs httptest.Server

Cuando pruebas lógica de red en Go, el error más común es confundir el nivel de abstracción necesario. Para unit testing de handlers, usamos httptest.NewRecorder(), que es una implementación en memoria de http.ResponseWriter. No hay sockets, no hay TCP, ni latencia; simplemente un buffer que captura lo que el handler intenta escribir. Esto es ideal para validar status codes, headers y el body de forma extremadamente rápida. Para tests de integración, donde necesitas validar que un http.Client realmente pueda negociar una conexión, levantar un servidor real con httptest.NewServer(handler) es la opción correcta. Este último abre un puerto aleatorio en 127.0.0.1 para evitar colisiones en entornos de CI, y es vital usar defer ts.Close() para liberar los descriptores de archivos del sistema operativo.

Si intentas usar un Recorder para probar un cliente HTTP, el test fallará porque el cliente intentará realizar una llamada de red hacia una dirección que no existe, ya que el Recorder solo vive en la memoria de tu proceso. Por otro lado, si olvidas cerrar un Server en un loop de tests, terminarás con un error de “too many open files”.

package example_test

import (
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// AuthMiddleware añade una cabecera de validación para demostrar el testeo de middleware.
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-API-Key") != "secret-token" {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// FinalHandler devuelve un JSON sencillo.
func FinalHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"status":"ok"}`))
}

func TestHTTPStack(t *testing.T) {
	// El handler final que queremos testear, envuelto por el middleware.
	handlerChain := AuthMiddleware(http.HandlerFunc(FinalHandler))

	t.Run("Unit: ResponseRecorder", func(t *testing.T) {
		// Usamos NewRecorder para testear el handler directamente sin red.
		req := httptest.NewRequest("GET", "/data", nil)
		req.Header.Set("X-API-Key", "secret-token")
		rec := httptest.NewRecorder()

		// Ejecutamos el handler pasándole el recorder como ResponseWriter.
		handlerChain.ServeHTTP(rec, req)

		if rec.Code != http.StatusOK {
			t.Errorf("esperado 200 OK, obtenido %d", rec.Code)
		}

		if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
			t.Errorf("body incorrecto: %s", rec.Body.String())
		}

		// Verificamos que el header de contenido sea el esperado.
		if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
			t.Errorf("header Content-Type erróneo: %s", ct)
		}
	})

	t.Run("Integration: NewServer", func(t *testing.T) {
		// Levantamos un servidor real en un puerto aleatorio.
		ts := httptest.NewServer(handlerChain)
		defer ts.Close() // Crucial para no dejar sockets huérfanos.

		// El cliente HTTP real interactúa con la URL dinámica del servidor de test.
		client := ts.Client()
		req, _ := http.NewRequest("GET", ts.URL, nil)
		req.Header.Set("X-API-Key", "secret-token")

		resp, err := client.Do(req)
		if err != nil {
			t.Fatalf("error en la llamada: %v", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusOK {
			t.Errorf("esperado 200, obtenido %d", resp.StatusCode)
		}

		body, _ := io.ReadAll(resp.Body)
		if string(body) != `{"status":"ok"}` {
			t.Errorf("body unexpected: %s", string(body))
		}
	})
}

Desglose técnico

En el primer subtest (Unit), httptest.NewRecorder() nos entrega un objeto que implementa la interfaz http.ResponseWriter. Cuando llamamos a handlerChain.ServeHTTP(rec, req), el middleware y el handler final escriben directamente en los buffers internos del rec. No hay paso por la pila TCP/IP, por lo que el test es puramente lógico y extremadamente veloz. Es la forma ideal de validar que AuthMiddleware realmente detiene la ejecución si la cabecera X-API-Key es incorrecta.

En el segundo subtest (Integration), httptest.NewServer realmente inicializa un net.Listener en un puerto libre. Esto es fundamental cuando el código que estás probando no es solo el handler, sino la lógica de un cliente HTTP que debe lidar con timeouts, redirecciones o certificados TLS. Usamos ts.URL para construir la petición, ya que no podemos asumir que el servidor estará en :8080. El uso de ts.Client() es una buena práctica porque devuelve un *http.Client preconfigurado para comunicarse correctamente con ese servidor específico.

El error frecuente

Un error clásico de nivel intermedio/avanzado ocurre al intentar testear un cliente HTTP usando NewRecorder.

// ESTO NO FUNCIONA Y CAERÁ EN RUNTIME
rec := httptest.NewRecorder()
client := &http.Client{}
// El cliente intentará resolver una URL, pero rec no es un servidor, 
// es solo un objeto que implementa una interfaz de escritura.
resp, err := client.Get("http://recorder-not-a-server") 

Si tu código es un cliente (por ejemplo, un SDK que consume una API), el Recorder no te sirve para el test de integración. Para eso, necesitas NewServer. El Recorder solo es útil para probar el lado del servidor (el http.Handler).

158

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio