🧪 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.
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)dondeXxxempieza por mayúscula. Los archivos_test.gose compilan aparte y NO forman parte del binario final.t.Errorfmarca el test como fallido pero sigue;t.Fatallo 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.Runmuestra 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-benchmemde 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=FuzzDividirse ejecutan primero los tests, luego fuzzea. Cuando encuentra un fallo, guarda el input entestdata/fuzz/y el test falla: ejecuta ese caso engo testpara 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.
💡
-racedebe 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. Corrego test -raceen 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)
}
}
⚠️
NewServerusa una conexión real (127.0.0.1:puerto aleatorio). Perfecto para probar el stack HTTP completo y clientes.NewRecorderes más rápido porque no hay red. Regla: unitario →NewRecorder; integración →NewServer. En ambos casos, cierra condeferpara 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
fakemanual de 10 líneas es legible y sin dependencias. Para casos complejos existentestifyymockery(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
- Documentación oficial del paquete testing: la referencia completa.
- Cómo escribir tests en Go (guía oficial): los fundamentos.
- Testify: aserciones y toolkit de testing.
- Guía de fuzzing oficial: cómo fuzzear bien.