El fuzz testing es una técnica de testing basada en propiedades que utiliza generadores de entrada aleatorios para descubrir estados de error que el programador no previó. A diferencia de un test unitario convencional, donde tú defines el input de forma manual (input="user_id=123"), el fuzzer muta bits y bytes para intentar romper la lógica de tu código.
El motor de fuzzing nativo de Go [disponible desde Go 1.18] es coverage-guided. Esto significa que el fuzzer no lanza basura al azar de forma ciega; utiliza instrumentación en tiempo de compilación para observar qué caminos del código se están ejecutando. Si una mutación de un byte logra que el programa entre en un nuevo if, un nuevo case o una nueva rama de un switch, ese input se considera “interesante” y se guarda en el corpus de trabajo. A partir de ahí, el fuzzer mutará ese nuevo input para intentar profundizar aún más en la estructura del dato.
Debes usarlo cuando estés implementando parsers (JSON, protocolos binarios, formatos de configuración), funciones de deserialización o cualquier lógica que procese datos externos que no controlas (como el cuerpo de un HTTP request). Si tu función manipula strings, slices o bytes, el fuzzing es la herramienta más barata para encontrar ataques de Denegación de Servicio (DoS) por panic o errores de índice fuera de rango.
Si implementas fuzzing pero solo te limitas a verificar que la función no explote, estarás desperdiciando ciclos de CPU. El fuzzer es extremadamente hábil encontrando crashes y panics, pero su verdadero poder reside en validar invariantes: propiedades que deben cumplirse siempre, independientemente del input. Si no defines estas reglas, el fuzzer pasará de largo ante bugs semánticos que no rompen el runtime pero corrompen los datos.
package main
import (
"errors"
"fmt"
"strings"
"testing"
)
// ParseKV intenta extraer una clave y un valor de un string con formato "key=value".
// La lógica exige que la clave y el valor no contengan el caracter '='.
func ParseKV(input string) (key, value string, err error) {
if len(input) == 0 {
return "", "", errors.New("input vacío")
}
// Si el input no tiene un '=', Split devolverá un slice de tamaño 1.
parts := strings.Split(input, "=")
if len(parts) != 2 {
return "", "", errors.New("formato inválido: falta el delimitador '='")
}
key, value = parts[0], parts[1]
// Validamos que no haya claves o valores vacíos tras el split.
if key == "" || value == "" {
return "", "", errors.New("clave o valor no pueden estar vacíos")
}
return key, value, nil
}
// FuzzParseKV es la función de fuzzing para probar ParseKV.
func FuzzParseKV(f *testing.F) {
// 1. Seed Corpus: Añadimos inputs válidos para guiar al fuzzer.
// Sin estas "semillas", el fuzzer tardaría mucho más en encontrar
// estructuras que pasen las primeras validaciones.
f.Add("user_id=12345")
f.Add("mode=debug")
f.Add("status=active")
// 2. La función de fuzz recibe *testing.F y la función de prueba recibe *testing.T.
f.Fuzz(func(t *testing.T, input string) {
key, value, err := ParseKV(input)
// Si el parseo fue exitoso, debemos verificar invariantes.
if err == nil {
// Invariante 1: Reconstrucción (Round-trip).
// Si reconstruimos el string con el delimitador, debe ser igual al original.
reconstructed := fmt.Sprintf("%s=%s", key, value)
if reconstructed != input {
t.Errorf("Error de reconstrucción: input %q no coincide con %q", input, reconstructed)
}
// Invariante 2: Integridad del delimitador.
// La clave y el valor extraídos no deben contener el signo '='.
if strings.Contains(key, "=") || strings.Contains(value, "=") {
t.Errorf("La clave %q o el valor %q contienen el delimitador '='", key, value)
}
}
// Nota: No necesitamos verificar si 'err != nil' explícitamente para la mayoría de los casos,
// porque el fuzzer ya está probando inputs que causarán errores de forma natural.
// El objetivo es asegurar que, si no hay error, la lógica es matemáticamente consistente.
})
}
Desglose del análisis
En ParseKV, el fuzzer buscará activamente casos donde strings.Split devuelva más de dos partes o donde la longitud del slice sea impredecible.
En FuzzParseKV, hemos configurado el Seed Corpus mediante f.Add. Esto es vital: si solo le diéramos al fuzzer basura, el motor de cobertura vería que la mayoría de los inputs fallan en la primera línea (len(input) == 0 o len(parts) != 2) y no lograría “saltar” hacia la lógica de asignación de variables. Las semillas le dan un mapa inicial de qué forma debe tener el dato para penetrar en el código.
Dentro de f.Fuzz, hemos implementado una invariante de reconstrucción. Fíjate en la línea reconstructed := fmt.Sprintf("%s=%s", key, value). Esta es la prueba de fuego: si tu función ParseKV es capaz de separar una cadena, pero la lógica interna falla de tal manera que la clave y el valor no pueden volver a unirse para formar la cadena original, has encontrado un bug de corrupción de datos, aunque el programa no haya crasheado.
Para ejecutar este test en modo fuzzing, debes usar:
go test -fuzz=FuzzParseKV -fuzztime=30s
El flag -fuzztime es esencial en CI/CD para evitar que el proceso se ejecute indefinidamente; lo ideal es dejar que corra durante unos minutos para capturar regresiones de cobertura.
El error frecuente
Un error muy común es no definir invariantes y limitarse a probar la “ruta feliz” o simplemente ignorar los resultados cuando ocurre un error.
// ERROR: Fuzzing inútil
f.Fuzz(func(t *testing.T, input string) {
key, value, err := ParseKV(input)
// Si no verificas nada sobre key, value o err,
// el fuzzer solo te avisará si el código lanza un panic.
// No estás testeando la lógica, solo la estabilidad del runtime.
_ = key
_ = value
_ = err
})
Si el código anterior es lo único que tienes, el fuzzer solo te será útil si alguien introduce un error de tipo index out of range o un nil pointer dereference. Un bug donde ParseKV("a=b=c") devuelva key="a" y value="b" sin marcar error sería invisible para este test, a pesar de ser un bug de lógica evidente.
N° 244