🔎 Buscar

🧪 Testing en Go

El paquete testing, table-driven tests, subtests, benchmarks, fuzzing, race detector y httptest: cómo escribir tests profesionales para tu código y tu API.

Wiki / Apuntes📖 Contenido

Testing en Go

El testing en Go es parte del lenguaje, no un añadido. El paquete testing está integrado en la toolchain: escribes archivos _test.go, funciones TestXxx y ejecutas go test. No hay frameworks mágicos — la filosofía es simple y directa: una función de test que falla con t.Errorf. Y con eso construyes suites profesionales.

Tu primer test

Cada paquete puede tener tests en archivos _test.go. Una función de test empieza por Test, recibe *testing.T y se ejecuta con go test:

// main.go
package main

func Sumar(a, b int) int {
	return a + b
}

// main_test.go
package main

import "testing"

func TestSumar(t *testing.T) {
	if got := Sumar(2, 3); got != 5 {
		t.Errorf("Sumar(2, 3) = %d; quiero 5", got)
	}
}
go test ./...

💡 Convenciones de nombres. Un test debe ser una función TestXxx(t *testing.T) donde Xxx empieza por mayúscula. Los archivos _test.go se compilan aparte y NO forman parte del binario final. t.Errorf marca el test como fallido pero sigue; t.Fatal lo detiene.

Table-driven tests: el patrón estándar

El patrón más usado en el ecosistema Go. En lugar de un test por caso, defines una tabla de casos y los iteras. Para una función simple como EsPar(n int) bool:

func TestEsPar(t *testing.T) {
	casos := []struct {
		nombre string
		n      int
		want   bool
	}{
		{"número par", 4, true},
		{"número impar", 7, false},
		{"cero", 0, true},
		{"negativo", -3, false},
	}

	for _, tc := range casos {
		t.Run(tc.nombre, func(t *testing.T) {
			if got := EsPar(tc.n); got != tc.want {
				t.Errorf("EsPar(%d) = %v; quiero %v", tc.n, got, tc.want)
			}
		})
	}
}

💡 La tabla es tu documentación. Cada fila es un caso con nombre (nombre), y al fallar, t.Run muestra cuál. Añadir casos nuevos es solo añadir una fila. Es el estándar por legible, extensible y por cubrir los bordes sin duplicar código.

Subtests con t.Run

t.Run anida tests: aislamiento (cada subtest puede saltarse/paralelizarse), identificación (sabes cuál falló) y t.Parallel() (ejecución en paralelo).

func TestServicio(t *testing.T) {
	t.Run("crear", func(t *testing.T) { /* ... */ })
	t.Run("actualizar", func(t *testing.T) { /* ... */ })
	t.Run("eliminar", func(t *testing.T) { /* ... */ })
}
go test -run 'TestServicio/crear' ./...   # ejecutar un subtest concreto

Para paralelizar subtests, llama a t.Parallel() al inicio de cada subtest, sin datos compartidos (en Go < 1.22, capturando tc := tc dentro del bucle).

t.Helper(): helpers limpios

Cuando extraes la lógica de comprobación a una función, sin t.Helper() el error apunta a la línea del helper, no a la del caso. t.Helper() corrige eso:

func assertIgual(t *testing.T, got, want int) {
	t.Helper() // el error se reporta en el CALLER, no aquí
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

func TestSumar(t *testing.T) {
	assertIgual(t, Sumar(2, 3), 5)
	assertIgual(t, Sumar(-1, 1), 0)
}

⚠️ t.Helper() siempre al principio de la función helper. Sin él, cuando un caso falla ves la línea dentro del helper (que es idéntica para todos) y no cuál caso fue. Con él, el fallo apunta a la línea exacta del caller.

Benchmarks: mide tu código

Los benchmarks miden rendimiento. Una función BenchmarkXxx(b *testing.B) que ejecuta el código bajo test con b.N (Go lo ajusta automáticamente para tener mediciones fiables):

func BenchmarkSumar(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Sumar(i, i)
	}
}
go test -bench=. -benchmem ./...
# BenchmarkSumar-8   1000000000   0.2436 ns/op   0 B/op   0 allocs/op

La línea indica: nº de iteraciones, ns/op (tiempo por operación), B/op (bytes asignados) y allocs/op (allocations por operación) — el más importante para optimizar. 💡 b.ReportAllocs() dentro del benchmark equivale a -benchmem de forma permanente: es la métrica nº1 para saber si una función es eficiente, pues las allocations al heap son el coste oculto más común.

Fuzzing: input aleatorio para encontrar bugs

El fuzzing lanza entradas aleatorias (y mutaciones de las buenas) buscando pánics o invarianzas rotas. Una función FuzzXxx(f *testing.F) con semillas (casos base) y una propiedad que debe cumplirse siempre. Aquí, Dividir (que devuelve 0 si b==0) debe cumplir que con b==0 el resultado sea 0:

func FuzzDividir(f *testing.F) {
	f.Add(10, 2) // semillas: casos base válidos
	f.Add(-10, 5)
	f.Add(100, -4)

	f.Fuzz(func(t *testing.T, a, b int) {
		res := Dividir(a, b)
		if b == 0 && res != 0 {
			t.Errorf("con b=0, resultado debe ser 0, obtuve %d", res)
		}
	})
}
go test -fuzz=FuzzDividir ./...   # fuzzear hasta que falla o lo detienes

⚠️ El fuzzing solo empieza en el paquete indicado. Con -fuzz=FuzzDividir se ejecutan primero los tests, luego fuzzea. Cuando encuentra un fallo, guarda el input en testdata/fuzz/ y el test falla: ejecuta ese caso en go test para reproducirlo. Sin -fuzz, solo corre los casos de fuzz como tests normales.

Race detector: data races al descubierto

Cuando dos goroutines acceden a la misma variable y al menos una escribe, tienes una data race — comportamiento indefinido. Un ejemplo trivial: contador++ lanzado desde 1000 goroutines sobre la misma variable. El detector te la señala:

go test -race ./...

Si hay una race, el test falla e imprime WARNING: DATA RACE con la pila de llamadas que la causa.

💡 -race debe estar en tu CI siempre. Compila el programa con instrumentación de detección de races. Tiene coste (más lento, más memoria) pero solo en build/test, no en producción. Corre go test -race en cada push: es la forma más barata de encontrar el bug de concurrencia más caro.

httptest: testea tu API sin levantar servidor

Dos herramientas: httptest.NewRecorder (captura la respuesta de un handler sin red) y httptest.NewServer (un servidor real para tests de integración).

NewRecorder: prueba handlers directamente

httptest.NewRecorder captura la respuesta de un handler sin red. Para validar JSON, deserializa en una struct y compara campos (evita comparar strings exactos con espacios):

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

func holaHandler(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("nombre")
	if name == "" {
		http.Error(w, "falta nombre", http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(`{"saludo":"hola ` + name + `"}`))
}

type Respuesta struct {
	Saludo string `json:"saludo"`
}

func TestHolaHandler(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/hola?nombre=ana", nil)
	rec := httptest.NewRecorder()

	holaHandler(rec, req) // invoca el handler directamente

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, quiero %d", rec.Code, http.StatusOK)
	}
	var resp Respuesta
	if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
		t.Fatalf("JSON inválido: %v", err)
	}
	if resp.Saludo != "hola ana" {
		t.Errorf("saludo = %q, quiero %q", resp.Saludo, "hola ana")
	}
}

NewServer: servidor real de prueba

Para tests de integración que necesitan HTTP de verdad (rutas, middlewares, HTTP client):

func TestServidor(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("/hola", holaHandler)
	srv := httptest.NewServer(mux) // arranca en 127.0.0.1:puerto
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/hola?nombre=ana")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "hola ana") {
		t.Errorf("status=%d body=%s", resp.StatusCode, body)
	}
}

⚠️ NewServer usa una conexión real (127.0.0.1:puerto aleatorio). Perfecto para probar el stack HTTP completo y clientes. NewRecorder es más rápido porque no hay red. Regla: unitario → NewRecorder; integración → NewServer. En ambos casos, cierra con defer para no filtrar recursos.

Mocks manuales con interfaces

Go no necesita un framework de mocking: defines una interfaz y un fake en el test, y pruebas servicios sin base de datos real.

// servicio.go
type Repo interface {
	GetUsuario(id int) (string, error)
}
type Servicio struct {
	repo Repo
}

func (s *Servicio) Saludo(id int) (string, error) {
	n, err := s.repo.GetUsuario(id)
	if err != nil {
		return "", err
	}
	return "Hola, " + n, nil
}

// servicio_test.go
type fakeRepo struct{}

func (fakeRepo) GetUsuario(id int) (string, error) {
	if id == 404 {
		return "", fmt.Errorf("no existe")
	}
	return "ana", nil
}

func TestServicioSaludo(t *testing.T) {
	s := Servicio{repo: fakeRepo{}}

	got, err := s.Saludo(1)
	if err != nil {
		t.Fatal(err)
	}
	if got != "Hola, ana" {
		t.Errorf("got %q", got)
	}
}

💡 Interfaces pequeñas + fakes manuales es el patrón idiomático de Go. Empieza simple: un fake manual de 10 líneas es legible y sin dependencias. Para casos complejos existen testify y mockery (generación de mocks).

Cobertura

go test -cover te dice qué porcentaje de líneas se ejecutaron. Para un informe detallado por archivo, genera un profile y ábrelo en el navegador:

go test -cover ./...
# ok  example  0.245s  coverage: 84.2% of statements

go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out   # informe visual en el navegador

⚠️ La cobertura mide líneas ejecutadas, no calidad. 100% de cobertura no garantiza que no haya bugs. Úsala para detectar zonas muertas (código que nunca se prueba), no como meta en sí. Prioriza los caminos críticos (handlers, lógica de negocio, manejo de errores).

Ejemplo completo: tests para una API REST

Una API real con handler, JSON y tests table-driven de todos sus caminos (éxito, no encontrado, error de entrada):

// api.go — el handler
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
)

type Usuario struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

var store = map[int]Usuario{1: {1, "ana"}}

func usuarioHandler(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, "/usuarios/")
	var id int
	if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
		http.Error(w, `{"error":"id inválido"}`, http.StatusBadRequest)
		return
	}
	u, ok := store[id]
	if !ok {
		http.Error(w, `{"error":"no encontrado"}`, http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(u)
}
// api_test.go — los tests
package main

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

func TestUsuarioHandler(t *testing.T) {
	casos := []struct {
		nombre   string
		path     string
		wantCode int
		wantName string
	}{
		{"usuario existe", "/usuarios/1", http.StatusOK, "ana"},
		{"usuario no existe", "/usuarios/99", http.StatusNotFound, ""},
		{"id inválido", "/usuarios/abc", http.StatusBadRequest, ""},
	}

	for _, tc := range casos {
		t.Run(tc.nombre, func(t *testing.T) {
			rec := httptest.NewRecorder()
			usuarioHandler(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))

			if rec.Code != tc.wantCode {
				t.Fatalf("status = %d, quiero %d", rec.Code, tc.wantCode)
			}
			if tc.wantName != "" {
				var u Usuario
				if err := json.NewDecoder(rec.Body).Decode(&u); err != nil {
					t.Fatalf("JSON inválido: %v", err)
				}
				if u.Name != tc.wantName {
					t.Errorf("name = %q, quiero %q", u.Name, tc.wantName)
				}
			}
		})
	}
}

func BenchmarkUsuarioHandler(b *testing.B) {
	req := httptest.NewRequest(http.MethodGet, "/usuarios/1", nil)
	for i := 0; i < b.N; i++ {
		usuarioHandler(httptest.NewRecorder(), req)
	}
}

Ejecuta todo junto:

go test -race -cover -bench=. -benchmem ./...

Cheatsheet

Quieres… Usa…
Test básico func TestXxx(t *testing.T)
Muchos casos Table-driven test con t.Run
Fallo con continuar/detener t.Errorf / t.Fatalf
Helpers con errores claros t.Helper()
Medir rendimiento func BenchmarkXxx(b *testing.B) + b.ReportAllocs()
Buscar bugs con input aleatorio func FuzzXxx(f *testing.F) + go test -fuzz
Detectar data races go test -race
Probar un handler httptest.NewRecorder
Probar un servidor real httptest.NewServer
Fakes sin dependencias Interfaces + structs manuales
Porcentaje cubierto go test -cover

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.