¿Qué es “testing” en Go y qué problema resuelve?
En Go, las pruebas unitarias se escriben con el paquete estándar testing. La idea es validar comportamiento de funciones y métodos de forma automática, repetible y rápida. Un buen set de tests te permite refactorizar con confianza, detectar regresiones y documentar el contrato de tu código (qué debe hacer ante entradas válidas e inválidas).
Go favorece pruebas simples: archivos *_test.go, funciones TestXxx, y ejecución con go test. A partir de ahí puedes escalar a subtests, tablas de casos, fakes/mocks por interfaces, servidores HTTP de prueba, benchmarks y perfiles.
Estructura básica de una prueba unitaria
1) Archivo y convención
- El archivo debe terminar en
_test.go(por ejemplo,math_test.go). - Las funciones de test deben llamarse
TestAlgoy recibir*testing.T. - Para ejecutar:
go test ./...
2) Aserciones manuales (sin librerías externas)
En Go es común comparar valores y, si no coinciden, fallar con t.Fatalf o t.Errorf. Usa Fatal si no tiene sentido continuar el test.
package mathx
func Sum(a, b int) int { return a + b }
package mathx
import "testing"
func TestSum(t *testing.T) {
got := Sum(2, 3)
want := 5
if got != want {
t.Fatalf("Sum(2,3) = %d; want %d", got, want)
}
}
3) Comparaciones útiles: errores, structs y slices
Para errores, normalmente se valida si existe o no, y opcionalmente su tipo o mensaje. Para structs/slices/mapas, puedes comparar campo a campo o usar reflect.DeepEqual (con cuidado: puede ser demasiado estricto o frágil).
import (
"errors"
"reflect"
"testing"
)
func TestSomething(t *testing.T) {
gotErr := errors.New("boom")
if gotErr == nil {
t.Fatal("expected error")
}
got := []int{1, 2}
want := []int{1, 2}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v; want %#v", got, want)
}
}
Subtests: organizar escenarios dentro de un mismo test
Los subtests con t.Run permiten agrupar escenarios y verlos como casos independientes en la salida. También facilitan ejecutar casos en paralelo cuando sea seguro.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
func TestNormalizeName(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"trim", " Ana ", "Ana"},
{"empty", " ", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := NormalizeName(tc.in)
if got != tc.want {
t.Fatalf("got %q; want %q", got, tc.want)
}
})
}
}
Si dentro del subtest usas t.Parallel(), recuerda un detalle importante: captura la variable del loop para evitar que todos los subtests lean el mismo valor.
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
Tablas de casos (table-driven tests): patrón idiomático
Las tablas de casos son el patrón más común en Go para probar múltiples entradas/salidas de forma compacta. Ventajas: menos duplicación, fácil agregar casos, y salida clara con nombres.
Guía paso a paso
- Define una estructura con campos de entrada y salida esperada.
- Declara un slice de casos con nombres descriptivos.
- Itera y ejecuta cada caso con
t.Run. - Valida resultados y errores de forma explícita.
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
var ErrDivideByZero = errors.New("divide by zero")
func TestDivide(t *testing.T) {
cases := []struct {
name string
a, b float64
want float64
wantErr error
}{
{"ok", 10, 2, 5, nil},
{"zero", 10, 0, 0, ErrDivideByZero},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Divide(tc.a, tc.b)
if tc.wantErr == nil && err != nil {
t.Fatalf("unexpected err: %v", err)
}
if tc.wantErr != nil {
if err == nil {
t.Fatalf("expected err %v", tc.wantErr)
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v; want %v", err, tc.wantErr)
}
return
}
if got != tc.want {
t.Fatalf("got %v; want %v", got, tc.want)
}
})
}
}
Mocks con interfaces: fakes simples y pruebas deterministas
En Go, en lugar de “mockear” clases, normalmente defines una interfaz pequeña (solo lo que necesitas) y en tests inyectas una implementación fake. Esto reduce acoplamiento y hace las pruebas más deterministas (sin red, sin reloj real, sin dependencias externas).
Ejemplo: servicio que depende de un repositorio
type User struct {
ID string
Name string
}
type UserStore interface {
ByID(id string) (User, error)
}
type UserService struct {
Store UserStore
}
func (s UserService) DisplayName(id string) (string, error) {
u, err := s.Store.ByID(id)
if err != nil {
return "", err
}
if u.Name == "" {
return "(unknown)", nil
}
return u.Name, nil
}
Fake para el test
type fakeUserStore struct {
users map[string]User
err error
}
func (f fakeUserStore) ByID(id string) (User, error) {
if f.err != nil {
return User{}, f.err
}
u, ok := f.users[id]
if !ok {
return User{}, errors.New("not found")
}
return u, nil
}
func TestUserService_DisplayName(t *testing.T) {
svc := UserService{Store: fakeUserStore{users: map[string]User{"1": {ID: "1", Name: "Ana"}}}}
got, err := svc.DisplayName("1")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != "Ana" {
t.Fatalf("got %q; want %q", got, "Ana")
}
}
Spy (fake que registra llamadas)
Cuando necesitas verificar interacciones (por ejemplo, que se llamó con cierto ID), un “spy” guarda parámetros.
type spyStore struct {
calledWith []string
user User
}
func (s *spyStore) ByID(id string) (User, error) {
s.calledWith = append(s.calledWith, id)
return s.user, nil
}
func TestUserService_CallsStore(t *testing.T) {
spy := &spyStore{user: User{ID: "9", Name: "Bob"}}
svc := UserService{Store: spy}
_, _ = svc.DisplayName("9")
if len(spy.calledWith) != 1 || spy.calledWith[0] != "9" {
t.Fatalf("store calledWith = %#v; want [\"9\"]", spy.calledWith)
}
}
Pruebas de componentes HTTP con servidores de prueba
Para probar clientes HTTP o componentes que hacen requests, usa net/http/httptest. Esto crea un servidor real en memoria con un handler controlado por el test, evitando dependencias externas y haciendo el test rápido y determinista.
Cliente a probar
type APIClient struct {
BaseURL string
HTTP *http.Client
}
func (c APIClient) Health(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
if err != nil {
return "", err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(b))
}
return string(b), nil
}
Test con httptest.NewServer
func TestAPIClient_Health(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
c := APIClient{BaseURL: srv.URL, HTTP: srv.Client()}
got, err := c.Health(context.Background())
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != "ok" {
t.Fatalf("got %q; want %q", got, "ok")
}
}
Consejo: usa srv.Client() para heredar configuración adecuada (por ejemplo, TLS si usas NewTLSServer).
Benchmarks: medir rendimiento con testing.B
Los benchmarks en Go se escriben como BenchmarkXxx(b *testing.B) y se ejecutan con go test -bench=.. El framework ajusta automáticamente el número de iteraciones para obtener medidas estables.
func BenchmarkSum(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Sum(i, i+1)
}
}
Evitar sesgos comunes
- Si preparas datos, hazlo fuera del loop principal para no medir la preparación.
- Si necesitas resetear el temporizador:
b.ResetTimer(). - Si el benchmark asigna memoria, revisa asignaciones con
-benchmem.
func BenchmarkNormalizeName(b *testing.B) {
inputs := make([]string, 1000)
for i := range inputs {
inputs[i] = " Ana "
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NormalizeName(inputs[i%len(inputs)])
}
}
Ejecución típica: go test -bench=. -benchmem ./...
Perfiles básicos: detectar cuellos de botella (CPU y memoria)
Cuando un benchmark o una ruta crítica es lenta, puedes generar perfiles con flags de go test y analizarlos con go tool pprof.
CPU profile
- Generar:
go test -bench=BenchmarkNormalizeName -cpuprofile cpu.out ./tu/paquete - Analizar:
go tool pprof cpu.out - Comandos útiles dentro de pprof:
top,list NombreFuncion,web(si tienes Graphviz).
Mem profile
- Generar:
go test -bench=BenchmarkNormalizeName -memprofile mem.out ./tu/paquete - Analizar:
go tool pprof mem.out
En memoria, suele ser útil ejecutar con -benchmem primero para ver si hay muchas asignaciones y luego perfilar para ubicar dónde ocurren.
Criterios de calidad en tests: cobertura útil, determinismo y organización
Cobertura útil (no “cobertura por cobertura”)
La cobertura indica qué líneas se ejecutaron, pero no garantiza que el comportamiento esté bien validado. Úsala como señal para encontrar zonas sin tests, no como objetivo absoluto.
- Medir cobertura:
go test -cover ./... - Reporte detallado:
go test -coverprofile=cover.out ./... && go tool cover -func=cover.out - Vista HTML:
go tool cover -html=cover.out
Prioriza casos: validaciones, ramas de error, límites (vacío, cero, máximos razonables), y contratos públicos.
Pruebas deterministas: elimina flakiness
Un test “flaky” falla de forma intermitente. Para evitarlos:
- No dependas de red externa, hora real o servicios compartidos; usa fakes y
httptest. - Evita
time.Sleeppara sincronización; prefiere señales explícitas (canales, hooks, callbacks) o tiempos controlados. - Si hay aleatoriedad, fija semilla o inyecta un generador.
- Si hay concurrencia, diseña el test para esperar condiciones con límites claros (timeouts) y señales, no por “esperar un poco”.
Organización de testdata
Go trata directorios llamados testdata como datos auxiliares: no se incluyen en builds normales y son ideales para fixtures (JSON, archivos, respuestas HTTP, etc.).
- Estructura típica:
tu/paquete/testdata/... - Acceso desde tests: rutas relativas al paquete (por ejemplo,
os.ReadFile("testdata/response.json")). - Evita generar archivos en el repo durante el test; si necesitas temporales, usa
t.TempDir().
func TestParseFixture(t *testing.T) {
b, err := os.ReadFile("testdata/user.json")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
got, err := ParseUser(b)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got.ID == "" {
t.Fatalf("expected ID")
}
}
Checklist práctico para escribir un buen test en Go
| Objetivo | Práctica recomendada |
|---|---|
| Claridad | Nombres de casos descriptivos en t.Run y mensajes de error con got/want |
| Escalabilidad | Tablas de casos para múltiples escenarios; subtests para agrupar |
| Aislamiento | Interfaces pequeñas + fakes; httptest para HTTP |
| Determinismo | Sin red externa, sin reloj real, sin sleeps arbitrarios |
| Rendimiento | Benchmarks con preparación fuera del loop; perfiles CPU/mem cuando haga falta |
| Datos de prueba | testdata para fixtures; t.TempDir() para temporales |