Módulo 14: Testing, Calidad de Software & Benchmarks
Pruebas que dan confianza para cambiar el código.
Fase 4: Rendimiento, observabilidad, escritorio y producción · Lección 17 de 20
🎯 Objetivos
- Escribir pruebas unitarias y de integración con el paquete
testing. - Probar handlers HTTP con
httptestsin levantar un servidor real. - Aplicar pruebas de tabla, subpruebas y dobles de prueba.
- Medir con benchmarks y descubrir fallos con fuzzing.
- Integrar calidad en el flujo de trabajo (cobertura, race detector, CI).
1. Por qué probar
Las pruebas automáticas son lo que te permite cambiar código sin miedo. Un buen conjunto de pruebas:
- Detecta regresiones al instante.
- Documenta el comportamiento esperado.
- Fomenta un diseño desacoplado (lo que ya hiciste con interfaces y repositorios).
Go incluye todo lo necesario en la biblioteca estándar: testing, httptest, go test. Sin frameworks.
Convenciones
- Los archivos de prueba terminan en
_test.goy se ubican junto al código. - Las funciones se llaman
TestXxx(t *testing.T),BenchmarkXxx(b *testing.B),FuzzXxx(f *testing.F). - Un archivo
foo_test.goenpackage fooprueba por dentro (acceso a lo privado). Enpackage foo_testprueba como un consumidor externo (recomendado para la API pública).
go test ./... # todo el proyecto
go test -v -run TestCrear # solo las que coincidan, con detalle
go test -race ./... # con el detector de carreras
go test -count=1 ./... # sin usar resultados cacheados
go test -cover ./... # cobertura
2. Tu primera prueba
package tarea_test
import (
"context"
"errors"
"testing"
"github.com/TU_USUARIO/tareas/internal/almacen/memoria"
"github.com/TU_USUARIO/tareas/internal/tarea"
)
func TestServicio_Crear_TituloVacio(t *testing.T) {
svc := tarea.NuevoServicio(memoria.Nuevo())
_, err := svc.Crear(context.Background(), " ")
if !errors.Is(err, tarea.ErrTituloVacio) {
t.Fatalf("Crear(vacío) error = %v; quería %v", err, tarea.ErrTituloVacio)
}
}
Herramientas de *testing.T:
| Método | Efecto |
|---|---|
t.Errorf(...) | Marca fallo y continúa |
t.Fatalf(...) | Marca fallo y detiene esa prueba |
t.Helper() | Oculta la función auxiliar de la traza del error |
t.Cleanup(f) | Ejecuta f al terminar (cierres, borrados) |
t.TempDir() | Directorio temporal que se elimina solo |
t.Setenv(k, v) | Variable de entorno restaurada al terminar |
t.Skip(...) | Omite la prueba (por ejemplo, sin base de datos) |
t.Parallel() | Ejecuta en paralelo con otras |
Estilo recomendado de mensaje: “qué hice, qué obtuve, qué quería”.
3. Pruebas de tabla y subpruebas
Es el patrón estrella de Go: una tabla de casos y un bucle. Añadir un caso es añadir una fila.
func TestFormTarea_Validar(t *testing.T) {
casos := []struct {
nombre string
titulo string
campoErr string // "" si no se esperan errores
}{
{"válido", "Estudiar Go", ""},
{"con espacios laterales", " hola ", ""},
{"vacío", "", "titulo"},
{"solo espacios", " \t ", "titulo"},
{"demasiado largo", strings.Repeat("a", 121), "titulo"},
{"121 runas multibyte", strings.Repeat("ñ", 121), "titulo"},
{"120 runas multibyte", strings.Repeat("ñ", 120), ""},
}
for _, c := range casos {
t.Run(c.nombre, func(t *testing.T) {
f := FormTarea{Titulo: c.titulo}
f.Validar()
_, hayError := f.Errores["titulo"]
if hayError != (c.campoErr != "") {
t.Errorf("Validar(%q): errores = %v; esperaba error en %q", c.titulo, f.Errores, c.campoErr)
}
})
}
}
Ventajas: cada caso es una subprueba con nombre (go test -run 'TestFormTarea_Validar/vacío'), los fallos se reportan por separado y se pueden paralelizar con t.Parallel().
Comparar valores complejos
La biblioteca estándar basta para casos simples (reflect.DeepEqual, slices.Equal, maps.Equal). Para diferencias legibles en estructuras grandes se usa github.com/google/go-cmp/cmp:
if diff := cmp.Diff(esperado, obtenido); diff != "" {
t.Errorf("(-esperado +obtenido):\n%s", diff)
}
Archivos golden
Para salidas largas (HTML renderizado) guarda el resultado esperado en testdata/ (carpeta ignorada por el compilador y por go test) y compáralo; con un indicador -update se regeneran.
4. Dobles de prueba (fakes, stubs, mocks)
Gracias a las interfaces pequeñas, sustituir dependencias es trivial. Para el repositorio ya tienes un fake real: memoria.Repo.
Cuando necesitas forzar un error, un stub a mano de pocas líneas basta, sin bibliotecas de mocks:
type repoQueFalla struct{ tarea.Repositorio } // incrusta la interfaz: solo sobrescribes lo que usas
func (repoQueFalla) Listar(context.Context) ([]tarea.Tarea, error) {
return nil, errors.New("conexión rota")
}
func TestServidor_Listar_ErrorInterno(t *testing.T) {
srv := nuevoServidorDePrueba(t, tarea.NuevoServicio(repoQueFalla{}))
// ... el handler debe responder 500 y NO mostrar "conexión rota" al usuario
}
Principios: prefiere fakes funcionales sobre mocks que verifican llamadas (las pruebas se acoplan a la implementación y se rompen al refactorizar); prueba comportamiento observable, no detalles internos.
5. Probar HTTP con net/http/httptest
httptest permite ejercitar handlers sin red.
httptest.NewRecorder: probar un handler directamente
func TestListar_OK(t *testing.T) {
repo := memoria.Nuevo()
repo.Crear(context.Background(), "Estudiar Go")
srv := NuevoServidor(tarea.NuevoServicio(repo), vistasDePrueba(t))
req := httptest.NewRequest(http.MethodGet, "/tareas", nil)
rec := httptest.NewRecorder()
srv.Rutas().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("estado = %d; quería %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
t.Errorf("Content-Type = %q", got)
}
if !strings.Contains(rec.Body.String(), "Estudiar Go") {
t.Errorf("el cuerpo no contiene la tarea:\n%s", rec.Body)
}
}
Formularios y cabeceras HTMX
func TestCrear_HTMX_Valido(t *testing.T) {
srv := nuevoServidorDePrueba(t, tarea.NuevoServicio(memoria.Nuevo()))
form := url.Values{"titulo": {"Nueva"}}
req := httptest.NewRequest(http.MethodPost, "/tareas", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
srv.Rutas().ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("estado = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), `hx-swap-oob`) {
t.Error("falta el fragmento OOB del contador")
}
}
func TestCrear_TituloVacio_422(t *testing.T) {
// ... mismo envío con titulo="" → esperar 422 y el mensaje de error en línea
}
httptest.NewServer: servidor real efímero
Útil para probar clientes HTTP y flujos completos (cookies, redirecciones):
ts := httptest.NewServer(srv.Rutas())
defer ts.Close()
jar, _ := cookiejar.New(nil)
cliente := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse // no seguir redirecciones: inspeccionarlas
}}
resp, err := cliente.PostForm(ts.URL+"/entrar", url.Values{"email": {"a@b.c"}, "password": {"secreto-largo"}})
// comprobar resp.StatusCode == 303 y que existe la cookie "sesion"
También sirve para simular APIs externas que consume tu código.
Probar middlewares
Un middleware es una función: pásale un Handler falso y verifica el efecto.
func TestExigirRol_Prohibido(t *testing.T) {
siguiente := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
h := ExigirRol("admin")(siguiente)
req := httptest.NewRequest("GET", "/admin", nil)
req = req.WithContext(ConUsuario(req.Context(), Usuario{Rol: "usuario"}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("estado = %d; quería 403", rec.Code)
}
}
6. Pruebas de integración con una base de datos real
Los fakes verifican la lógica, pero no que tu SQL funcione. Para el repositorio PostgreSQL necesitas una base de datos real. Opciones:
- Contenedor efímero con
testcontainers-go(levanta PostgreSQL por prueba, requiere Docker). - Una base de datos de pruebas aparte, apuntada por
TEST_DATABASE_URL.
func TestMain(m *testing.M) {
if os.Getenv("TEST_DATABASE_URL") == "" {
fmt.Println("omitiendo pruebas de integración: falta TEST_DATABASE_URL")
os.Exit(0)
}
os.Exit(m.Run())
}
func nuevoRepo(t *testing.T) *Repo {
t.Helper()
pool, err := pgxpool.New(context.Background(), os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(), "TRUNCATE tareas RESTART IDENTITY"); err != nil {
t.Fatal(err)
}
return Nuevo(pool)
}
Para separar pruebas lentas: go test -short ./... y if testing.Short() { t.Skip("lenta") }, o etiquetas de compilación (//go:build integration).
Contrato compartido: escribe una suite que reciba un tarea.Repositorio y la ejecutas contra ambas implementaciones (memoria y Postgres). Así el fake se mantiene fiel al real.
func SuiteRepositorio(t *testing.T, nuevo func(t *testing.T) tarea.Repositorio) {
t.Run("Obtener inexistente devuelve ErrNoEncontrada", func(t *testing.T) {
_, err := nuevo(t).Obtener(context.Background(), 999)
if !errors.Is(err, tarea.ErrNoEncontrada) {
t.Errorf("err = %v", err)
}
})
// ... más casos
}
7. Benchmarks
Miden rendimiento de una función. Se ejecutan con -bench.
func BenchmarkTop(b *testing.B) {
texto := strings.Repeat("go es simple go es rápido go ", 1000)
b.ReportAllocs()
for b.Loop() { // Go 1.24+; en versiones anteriores: for i := 0; i < b.N; i++
Top(texto, 5)
}
}
go test -bench=. -benchmem -run=^$ ./...
# BenchmarkTop-8 1500 780000 ns/op 200000 B/op 1200 allocs/op
(-run=^$ evita ejecutar las pruebas normales a la vez.) Aspectos prácticos:
- Ejecuta con
-count=10y compara versiones conbenchstat(go install golang.org/x/perf/cmd/benchstat@latest), porque una sola medición es ruido. b.ResetTimer()descarta la preparación previa;b.Runcrea sub-benchmarks (distintos tamaños de entrada).- Evita que el compilador elimine el cálculo (guarda el resultado en una variable de paquete).
- Perfila el benchmark:
go test -bench=Top -cpuprofile cpu.out && go tool pprof cpu.out. - Mide en máquina tranquila; los resultados dependen del hardware.
8. Fuzz testing
El fuzzing genera automáticamente entradas inesperadas para encontrar panics, bucles infinitos y violaciones de propiedades. Ideal para parsers, validadores y decodificadores.
func FuzzParsearPagina(f *testing.F) {
// Corpus inicial: casos "semilla"
for _, s := range []string{"1", "10", "-3", "abc", "", "99999999999999999999"} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, entrada string) {
p := ParsearPagina(entrada) // la función bajo prueba
// Propiedades que SIEMPRE deben cumplirse, sea cual sea la entrada
if p < 1 {
t.Errorf("ParsearPagina(%q) = %d; debe ser ≥ 1", entrada, p)
}
})
}
go test -fuzz=FuzzParsearPagina -fuzztime=30s
Si encuentra un fallo, guarda la entrada culpable en testdata/fuzz/FuzzParsearPagina/… y desde entonces go test normal la reproduce como prueba de regresión. Versiona esa carpeta.
La clave es expresar propiedades (invariantes), por ejemplo “decodificar(codificar(x)) == x” o “nunca hace panic”.
9. Calidad en el día a día
Cobertura
go test -coverprofile=cover.out ./...
go tool cover -html=cover.out # informe visual
go tool cover -func=cover.out | tail -1
La cobertura indica qué código no se ejecutó, no que esté bien probado. Persigue pruebas significativas, no un número (un 100 % sin aserciones no vale nada). Apunta a cubrir bien la lógica de dominio y las ramas de error.
Lista de verificación antes de cada commit
gofmt -l . # nada listado
go vet ./...
golangci-lint run
go test -race -count=1 ./...
govulncheck ./... # vulnerabilidades en dependencias
Integración continua (GitHub Actions)
.github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env: { POSTGRES_USER: tareas, POSTGRES_PASSWORD: tareas, POSTGRES_DB: tareas_test }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U tareas" --health-interval 5s --health-timeout 5s --health-retries 10
env:
TEST_DATABASE_URL: postgres://tareas:tareas@localhost:5432/tareas_test?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- run: go vet ./...
- uses: golangci/golangci-lint-action@v8
- run: go test -race -cover ./...
La pirámide de pruebas
/\ pocas: de extremo a extremo (navegador)
/ \ algunas: de integración (BD real, HTTP completo)
/____\ muchas: unitarias (rápidas, sin red ni BD)
🧪 Práctica
Cubre con pruebas de tabla el validador de formularios, prueba un handler con httptest.NewRecorder, añade un benchmark y un fuzz test para el parser de entrada.
Ver solución
- Tabla:
TestFormTarea_Validar(sección 3) con al menos 7 casos, incluida la frontera de 120/121 runas. - Handler:
TestListar_OK,TestCrear_HTMX_ValidoyTestCrear_TituloVacio_422conhttptest.NewRecorder; añade un caso de404paraGET /tareas/999. - Parser:
func ParsearPagina(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 {
return 1
}
return n
}
- Fuzz:
FuzzParsearPagina(sección 8), ejecútalo 30 s. Prueba a introducir un fallo (por ejemplo, devolvernsin comprobarn < 1) y observa cómo el fuzzer lo encuentra y lo guarda entestdata/fuzz/. - Benchmark:
BenchmarkParsearPaginayBenchmarkRenderListadocon-benchmem. - Calidad: ejecuta
go test -race -cover ./...y verifica que supera el 70 % eninternal/tareayinternal/web. - Añade el workflow de CI y confirma que corre en verde en GitHub.
⚠️ Errores comunes
- Pruebas que dependen del orden, del reloj o de datos compartidos entre pruebas.
time.Sleeppara esperar goroutines (usa sincronización o canales).- Mocks que verifican cada llamada interna (frágiles ante refactorizaciones).
- Ignorar
-racehasta tener un servidor en producción. - Perseguir el 100 % de cobertura en lugar de probar el comportamiento importante.
- Olvidar versionar
testdata/fuzz/.
📌 Resumen
testing+go test(con-race,-cover,-bench,-fuzz) cubren todo.- Pruebas de tabla con subpruebas; fakes y stubs a mano gracias a las interfaces pequeñas.
httptest.NewRecorderyhttptest.NewServerprueban HTTP sin infraestructura.- Integración con BD real para el SQL; una suite de contrato mantiene fiel al fake.
- Benchmarks miden; fuzzing explora lo inesperado; la CI lo automatiza.
✅ Autoevaluación
- ¿Qué ventajas tiene una prueba de tabla con subpruebas?
- ¿Cuándo usarías
NewRecordery cuándoNewServer? - ¿Por qué es preferible un fake funcional a un mock estricto?
- ¿Qué diferencia hay entre un test y un fuzz test? ¿Qué es una “propiedad”?
- ¿Qué significa una cobertura del 90 %? ¿Qué no te garantiza?
- Puedo explicar con mis palabras cada objetivo de esta lección.
-
go test -race ./...pasa en mi proyecto y la CI está en verde. - Sé qué parte me costó más y la repasaré.