Testing en Go: unitarias, tablas de casos y calidad de código

Capítulo 10

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

¿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 TestAlgo y 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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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.Sleep para 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

ObjetivoPráctica recomendada
ClaridadNombres de casos descriptivos en t.Run y mensajes de error con got/want
EscalabilidadTablas de casos para múltiples escenarios; subtests para agrupar
AislamientoInterfaces pequeñas + fakes; httptest para HTTP
DeterminismoSin red externa, sin reloj real, sin sleeps arbitrarios
RendimientoBenchmarks con preparación fuera del loop; perfiles CPU/mem cuando haga falta
Datos de pruebatestdata para fixtures; t.TempDir() para temporales

Ahora responde el ejercicio sobre el contenido:

Al ejecutar subtests en paralelo dentro de un loop de casos con t.Run y t.Parallel(), ¿qué práctica ayuda a evitar que todos los subtests usen el mismo valor del caso?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Cuando los subtests se ejecutan en paralelo, la variable del loop puede cambiar antes de que cada subtest la lea. Reasignar tc := tc dentro del for captura una copia por iteración y evita que todos usen el mismo valor.

Siguiente capítulo

Rendimiento y memoria en Go: profiling, optimización y buenas prácticas

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
83%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.