Fuzzing con el paquete testing

Avanzado
Go
Go
Actualizado: 19/04/2026

El fuzzing es una técnica que genera entradas aleatorias para descubrir bugs que los tests clásicos no encuentran. Go incorpora fuzzing nativo en el paquete testing, sin herramientas externas. Se integra con el mismo fichero _test.go y reutiliza los patrones que ya conoces de tests unitarios y benchmarks.

Anatomía de una función Fuzz

Las funciones de fuzzing comparten estructura con los tests: se declaran en un fichero _test.go, su nombre empieza por Fuzz y reciben un *testing.F. Dentro del cuerpo se añaden semillas con f.Add y se define la función de fuzz con f.Fuzz:

package reverso

import "testing"

func Invertir(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

func FuzzInvertir(f *testing.F) {
    // Semillas iniciales conocidas
    f.Add("hola")
    f.Add("")
    f.Add("áéíóú")

    f.Fuzz(func(t *testing.T, entrada string) {
        doble := Invertir(Invertir(entrada))
        if doble != entrada {
            t.Errorf("round-trip falló: Invertir(Invertir(%q)) = %q", entrada, doble)
        }
    })
}

La función interna recibe *testing.T y los mismos tipos que se han registrado con f.Add. Los tipos admitidos son string, []byte, bool, enteros con signo y sin signo, float32, float64 y rune. Combinar varios tipos en f.Add(a, b, ...) produce una función de fuzz con la misma firma.

Ejecución y flags relevantes

Sin flags adicionales, go test ejecuta la función Fuzz solo con las semillas, como si fuesen casos de un test normal. Para generar entradas aleatorias se añade -fuzz:

# Ejecutar solo las semillas (como test unitario)
go test

# Iniciar fuzzing con entradas aleatorias
go test -fuzz=FuzzInvertir

# Limitar tiempo (útil en CI)
go test -fuzz=FuzzInvertir -fuzztime=30s

# Número fijo de iteraciones
go test -fuzz=FuzzInvertir -fuzztime=1000x

Mientras el comando corre, Go muestra cuántas entradas probó, cuántas generaron un caso interesante y si se encontró un fallo. El fuzzing en modo sin límite solo se detiene cuando un caso falla o cuando pulsas Ctrl+C.

Pon la función Fuzz en el paquete del código que pruebas y asegúrate de que cubre la función con invariantes claras. Sin invariantes, el fuzzing encuentra entradas raras pero no sabe si el resultado es correcto.

Corpus generado y testdata

Cuando el fuzzing descubre una entrada que aumenta la cobertura o provoca un fallo, la guarda en testdata/fuzz/FuzzXxx/. Cada fichero es una entrada determinista con el siguiente formato textual:

go test fuzz v1
string("cadena reproductora")

A partir de ese momento, las futuras ejecuciones de go test cargarán ese corpus automáticamente como caso de test normal. De ese modo, los fallos detectados se convierten en regresiones permanentes y cualquier cambio futuro que reintroduzca el bug se rechaza en CI.

Esto permite trabajar con fuzzing sin preocuparse de perder hallazgos: si un desarrollador detecta un bug en local, basta con versionar el fichero en testdata/fuzz/FuzzXxx/ junto al código y el resto del equipo hereda automáticamente el caso.

Invariantes útiles

El fuzzing solo es valioso si la función Fuzz comprueba una propiedad verdadera para cualquier entrada. Los patrones más productivos son:

  • 1. Round-trip: serializar y deserializar debe devolver el valor original. Aplica a JSON, Base64, codificaciones binarias o funciones involutivas.
func FuzzJSONRoundTrip(f *testing.F) {
    f.Add([]byte(`{"id":1,"nombre":"ana"}`))
    f.Fuzz(func(t *testing.T, data []byte) {
        var u Usuario
        if err := json.Unmarshal(data, &u); err != nil {
            return // entrada no válida, descartamos
        }
        bytes, err := json.Marshal(u)
        if err != nil {
            t.Fatalf("Marshal falló tras Unmarshal: %v", err)
        }
        var u2 Usuario
        if err := json.Unmarshal(bytes, &u2); err != nil {
            t.Fatalf("Unmarshal del Marshal falló: %v", err)
        }
        if u != u2 {
            t.Errorf("round-trip distinto: %+v != %+v", u, u2)
        }
    })
}
  • 2. No pánico: la función no debe entrar en pánico para ninguna entrada. Este tipo de invariante es el más sencillo y descubre divisiones por cero, índices fuera de rango o accesos a punteros nulos.
func FuzzParsear(f *testing.F) {
    f.Add("1+2*3")
    f.Add("")
    f.Fuzz(func(t *testing.T, expr string) {
        _, _ = Parsear(expr) // no debe entrar en pánico
    })
}
  • 3. Oracle: comparar el resultado contra una implementación sencilla, lenta pero correcta, que sirva como referencia. Típico para sustituir algoritmos optimizados.
func FuzzOrdenacion(f *testing.F) {
    f.Add([]byte{3, 1, 4, 1, 5, 9, 2, 6})
    f.Fuzz(func(t *testing.T, data []byte) {
        nuestra := ordenarRapido(append([]byte{}, data...))
        lenta := ordenarBurbuja(append([]byte{}, data...))
        if !bytes.Equal(nuestra, lenta) {
            t.Errorf("ordenarRapido(%v) = %v; quiero %v", data, nuestra, lenta)
        }
    })
}

Semillas y diversidad de entradas

Aunque el fuzzing es aleatorio, las semillas iniciales guían la exploración. Cuantas más variantes incluyas en f.Add, mejor cobertura obtiene el motor desde el primer segundo. Una buena selección de semillas cubre:

  • Casos límite: cadena vacía, slice vacío, cero, valores negativos, el valor máximo del tipo.
  • Casos válidos conocidos: ejemplos reales del dominio (un JSON correcto, un nombre de usuario habitual).
  • Casos extraídos de bugs previos: cualquier entrada que causó un fallo en producción.
func FuzzValidarEmail(f *testing.F) {
    seeds := []string{"usuario@dominio.com", "", "sin-arroba", "a@b", "@dominio", "muy.largo@x.y"}
    for _, s := range seeds {
        f.Add(s)
    }
    f.Fuzz(func(t *testing.T, email string) {
        err := ValidarEmail(email)
        if err == nil && !strings.Contains(email, "@") {
            t.Errorf("email sin @ aceptado: %q", email)
        }
    })
}

Las semillas también se ejecutan en modo test normal (sin -fuzz). Así, los casos importantes quedan cubiertos aunque el fuzzing no se lance en local, solo en CI.

Reproducir y depurar un fallo

Cuando el fuzzing encuentra un fallo, imprime el fichero generado bajo testdata/fuzz/FuzzXxx/. Para ejecutar solo ese caso usamos -run:

go test -run=FuzzInvertir/8f3a1b2c

El identificador entre / es el hash del fichero. Esa ejecución carga la entrada correspondiente y permite adjuntar un depurador o añadir trazas sin tocar el código. También puedes editar manualmente el fichero para reducir la entrada mínima que reproduce el bug, un proceso conocido como minimización manual.

flowchart LR
    A[f.Add semillas] --> B[Motor de fuzzing]
    B --> C{Genera entrada}
    C -->|f.Fuzz| D[Invariante comprueba]
    D -->|falla| E[Guarda en testdata/fuzz]
    D -->|pasa| F[Mutación y siguiente iteración]
    E --> G[go test normal carga regresión]

Combinación con table-driven tests

Una función Fuzz no excluye tener también un TestXxx con casos explícitos. De hecho, el patrón recomendado es mantener ambos: el test clásico con casos conocidos de documentación, y el fuzz con invariantes generales. Si un fallo surge del fuzzing, conviene promoverlo a caso explícito en el table test para que aparezca en los logs normales de CI:

func TestInvertirCasos(t *testing.T) {
    casos := []struct {
        nombre, entrada, esperado string
    }{
        {"vacío", "", ""},
        {"palabra", "hola", "aloh"},
        {"unicode", "café", "éfac"},
    }
    for _, c := range casos {
        t.Run(c.nombre, func(t *testing.T) {
            if got := Invertir(c.entrada); got != c.esperado {
                t.Errorf("Invertir(%q) = %q; quiero %q", c.entrada, got, c.esperado)
            }
        })
    }
}

Fuzzing en CI

En pipelines de CI conviene ejecutar el fuzzing con un presupuesto temporal fijo para que nunca bloquee el pipeline. Un patrón típico combina una fase rápida en cada pull request y una fase larga programada:

# Fase rápida por PR
go test -fuzz=. -fuzztime=30s ./...

# Nightly con presupuesto mayor
go test -fuzz=. -fuzztime=30m ./...

Los hallazgos se persisten en testdata/fuzz. Si se versiona la carpeta, cada bug detectado queda bajo control de cambios y los integrantes del equipo lo reciben al hacer pull. Para evitar inflar el repositorio conviene minimizar los casos conservando solo las entradas realmente diferentes.

El fuzzing no sustituye a los tests unitarios, sino que los complementa. Es idóneo para parsers, funciones de serialización, algoritmos criptográficos de bajo nivel y utilidades que tratan entradas externas no confiables.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Go es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Go

Explora más contenido relacionado con Go y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Escribir funciones Fuzz con el paquete testing, sembrar casos con f.Add, aplicar invariantes de round-trip, reproducir fallos a partir del corpus y combinar fuzzing con table driven tests para una cobertura extensa.