Una de las cosas que sorprende a quien llega de otros lenguajes es que Go trae el testing incluido. No hay que instalar Jest, pytest ni ningún framework: el propio compilador sabe ejecutar tests.
El comando básico es go test ./..., que recorre todos los paquetes del módulo y ejecuta sus tests. Si quieres limitarte a un paquete concreto, go test ./pkg/nombre funciona igual de bien.
Los ficheros de test siguen una convención sencilla: cualquier archivo que termine en _test.go se trata como test. Dentro, las funciones de test tienen la forma func TestNombre(t *testing.T). El argumento t es el objeto que usas para reportar fallos.
Dos métodos que usarás constantemente:
t.Errorf(...): marca el test como fallido pero sigue ejecutando el resto de la función.t.Fatalf(...): marca el fallo y para inmediatamente. Útil cuando no tiene sentido continuar si algo falla.
Table-driven tests: el patrón estándar
Si miras el código fuente de la biblioteca estándar de Go, verás este patrón por todas partes. La idea es simple: defines una slice de casos de prueba como structs y luego la iteras.
func TestSuma(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positivos", 2, 3, 5},
{"negativos", -1, -2, -3},
{"cero", 0, 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Suma(tc.a, tc.b)
if got != tc.expected {
t.Errorf("Suma(%d, %d) = %d; quería %d", tc.a, tc.b, got, tc.expected)
}
})
}
}
La clave está en t.Run(name, fn): crea un subtest con nombre propio que puedes ejecutar individualmente con go test -run TestSuma/positivos. Esto viene muy bien cuando un caso concreto falla y quieres depurarlo sin correr todo lo demás.
¿Por qué domina este patrón? Porque separa los datos de la lógica de comprobación. Añadir un caso nuevo es cuestión de agregar una línea al struct. No hay que duplicar código ni copiar tests enteros.
Benchmarks: medir antes de optimizar
Go también tiene soporte nativo para benchmarks. La firma es casi igual que la de un test, pero con *testing.B en lugar de *testing.T:
func BenchmarkProcesarTexto(b *testing.B) {
texto := "ejemplo de texto largo..."
b.ResetTimer()
for i := 0; i < b.N; i++ {
ProcesarTexto(texto)
}
}
b.N es el número de iteraciones que Go decide automáticamente para obtener una medida estable. No lo fijas tú: el runner lo ajusta solo. b.ResetTimer() descarta el tiempo de setup que hayas hecho antes del bucle, como inicializar datos de prueba o abrir ficheros.
Para correr los benchmarks:
go test -bench=. -benchmem
El flag -benchmem añade dos columnas al resultado: B/op (bytes alojados por operación) y allocs/op (número de allocations). A menudo es más interesante que el tiempo puro, porque muchas allocations generan presión en el GC y ralentizan todo.
¿Cuándo merece la pena escribir un benchmark? Cuando necesitas justificar un cambio de implementación con datos reales o quieres detectar regresiones de rendimiento antes de que lleguen a producción. Sin un benchmark de referencia, no puedes saber si has mejorado o empeorado algo.
Fuzzing: dejar que Go encuentre los bugs
Desde Go 1.18 el fuzzer viene integrado en la herramienta estándar. La idea es que defines unos inputs de semilla y Go genera variaciones aleatorias buscando panics, fallos o comportamientos inesperados.
func FuzzParsearURL(f *testing.F) {
// Seeds iniciales
f.Add("https://ejemplo.com/path")
f.Add("")
f.Add("no-es-una-url")
f.Fuzz(func(t *testing.T, input string) {
// No debe hacer panic nunca
_, err := parsearURL(input)
if err != nil {
// Un error está bien, un panic no
return
}
})
}
Para activar el fuzzer:
go test -fuzz=FuzzParsearURL
Cuando Go encuentra un input que causa un fallo, lo guarda en testdata/fuzz/FuzzParsearURL/. La próxima vez que corras los tests normales, ese caso se reproduce automáticamente. Así el bug queda documentado y no puede volver a colarse.
El fuzzing brilla especialmente en funciones que procesan input externo: parsers, deserializadores, decodificadores de formatos binarios, funciones que manejan strings de usuario. Si algo puede recibir datos de fuera, vale la pena fuzzear.
testify: assertions sin complicaciones
La stdlib de Go es minimalista por diseño. t.Errorf funciona, pero cuando tienes muchas comprobaciones el código se vuelve verboso. Ahí entra github.com/stretchr/testify, la librería de assertions más usada en el ecosistema Go.
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCrearUsuario(t *testing.T) {
usuario, err := CrearUsuario("[email protected]")
require.NoError(t, err) // Para si hay error
assert.Equal(t, "[email protected]", usuario.Email)
assert.NotEmpty(t, usuario.ID)
}
La diferencia entre assert y require es la misma que entre t.Errorf y t.Fatalf: assert marca el fallo y sigue, require para inmediatamente. Para comprobaciones de error conviene usar require: si hay error, las siguientes comprobaciones probablemente paniquearán de todos modos.
Testify también incluye mock.Mock para generar mocks de interfaces sin herramientas de generación de código externas. Para proyectos que prefieren cero dependencias la stdlib basta, pero en proyectos con mucha lógica de negocio testify ahorra bastante tiempo.
httptest: tests de handlers HTTP sin levantar nada
Si trabajas con servicios HTTP en Go, el paquete net/http/httptest es imprescindible. Te permite testear handlers directamente, sin abrir puertos ni hacer peticiones reales.
func TestHandlerSaludo(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/saludo?nombre=Ana", nil)
rec := httptest.NewRecorder()
HandlerSaludo(rec, req)
resp := rec.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "Ana")
}
httptest.NewRecorder() es un http.ResponseWriter que captura todo lo que el handler escribe. httptest.NewRequest() construye una *http.Request con la URL y el método que quieras. El handler no sabe que está en un test.
Para tests de integración donde necesitas un cliente HTTP real, httptest.NewServer(handler) levanta un servidor en un puerto libre y te devuelve la URL. Lo cierras con defer ts.Close() al acabar el test.
-race: el detector de race conditions
Go tiene concurrencia de primera clase con goroutines y channels, pero eso también significa que las race conditions son un problema real. El compilador incluye un detector que puedes activar así:
go test -race ./...
Cuando el race detector está activo, el runtime monitoriza todos los accesos a memoria compartida y avisa si dos goroutines acceden a la misma variable sin sincronización. El aviso es preciso: te dice exactamente qué goroutines y en qué líneas.
El coste no es pequeño: entre 3 y 10 veces más lento y bastante más memoria. Por eso no lo activas en cada ejecución local, pero en CI siempre debería correr. Una race condition que llega a producción puede ser mucho más cara que un pipeline más lento.
-cover: cobertura de tests
La cobertura mide qué porcentaje de líneas de código ejecutan tus tests. Go la incluye de serie:
go test -cover ./...
Para ver los resultados en el navegador:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
Esto abre una página HTML donde cada línea está coloreada: verde si la cubren los tests, roja si no. Útil para ver de un vistazo qué ramas de un if nunca se ejercitan.
Una advertencia práctica: la cobertura es una guía, no un objetivo en sí. Un 90% de cobertura con tests que solo verifican el camino feliz vale menos que un 60% con tests que ejercitan casos límite reales. Lo que importa es la calidad de los tests, no el número.
Recursos relacionados
- Si te interesa cómo se aplica esto en producción, echa un vistazo a Go en proyectos de producción: calidad del código.
- Para comparar el testing de Go con Rust y ver cuándo cada enfoque encaja mejor.
Imagen: Pexels / Daniil Komov
