Testing en Go: Domina el paquete testing nativo (Test*, t.Errorf, t.Run) con ejemplos Go

Testing en Go: Domina el paquete testing nativo (Test*, t.Errorf, t.Run) con ejemplos

DUGLAS MORENODUGLAS MORENO 👁 16

Introducción

Si estás comenzando a trabajar con Go, seguramente ya sabés que el lenguaje viene con un paquete de testing nativo muy poderoso. Escribir pruebas en Go es un paso esencial para garantizar la calidad del código y, a la vez, se integra perfectamente con la cadena de herramientas (go test). En este artículo, vamos a profundizar en los conceptos básicos del paquete testing: las funciones que comienzan con Test*, el uso de t.Errorf para reportar fallos y la organización de pruebas con t.Run (subpruebas).

Si querés escribir pruebas confiables, mantener un código bien probado y entender cómo interpretar la salida de go test, este artículo es para vos. Vas a aprender con ejemplos concretos y vas a ver la salida esperada que obtienes al ejecutar go test. Al final, tendrás un conjunto de patrones que podés reutilizar en cada proyecto en el que trabajes.

Fundamentos del paquete testing de Go

Cómo se estructuran las pruebas

En Go, las pruebas se colocan en archivos que terminan en _test.go. Dentro de esos archivos, cualquier función que empiece con Test (seguida de una palabra) es considerada automáticamente una prueba cuando ejecutás go test. La firma es siempre:

func TestNombreDeLaPrueba(t *testing.T) {
    // cuerpo de la prueba
}

El primer parámetro es un *testing.T, que da acceso a métodos como Log, Error, Run y muchos más. El marco ejecuta cada Test* de forma secuencial (o en paralelo si llamás a t.Parallel), y al final imprime un breve reporte indicando si pasó o falló.

Configuración inicial

Para empezar a probar un paquete, creá un directorio, poné la implementación en mi_paquete/mi_modulo.go y creá un archivo mi_paquete/mi_modulo_test.go. Dentro del archivo de prueba, importás testing y mi_paquete (o lo que sea que quieras testear). Luego, definís tus Test*.

Una prueba básica: el caso más simple

Antes de meternos en detalles, vamos a ver una prueba trivial que verifica una pequeña función. Supongamos que tenemos una función Sum que recibe dos enteros egresa su suma.

// mi_paquete/mi_modulo.go
package mi_paquete

func Sum(a, b int) int {
    return a + b
}

Ahora, el archivo de prueba:

// mi_paquete/mi_modulo_test.go
package mi_paquete

import "testing"

func TestSum_Basico(t *testing.T) {
    // Caso de prueba: 2 + 3 debe ser 5
    resultado := Sum(2, 3)
    if resultado != 5 {
        t.Errorf("esperado 5, pero se obtuvo %d", resultado)
    }
}

Cuando ejecutás go test en el directorio mi_paquete, obtenés algo como:

--- RUN   TestSum_Basico
--- PASS: TestSum_Basico (0.00s)
--- PASS: test (0.00s)
PASS
ok   mi_paquete   0.001s

El código imprime “PASS” porque el resultado coincide con el esperado. Si cambiás el segundo operando a 4, la prueba fallaría y verías un trace de error.

Reportar errores con t.Errorf

Por qué usar t.Errorf

if resultado != 5 es una verificación simple, pero cuando fallan, querés un mensaje claro que explique qué salió mal. t.Errorf imprime el mensaje y registra el fallo sin detener inmediatamente la ejecución de la prueba. Eso permite que puedas reportar todos los errores de una vez al final.

Ejemplo con múltiples casos

Veamos una función que suma tres números y probémosla con varios valores de entrada usando un slice. Esto también nos da la oportunidad de mostrar cómo podés iterar en una prueba.

// mi_paquete/multi_sum_test.go
package mi_paquete

import "testing"

func TestMultiSum(t *testing.T) {
    casos := []struct {
        a, b, esperado int
    }{
        {1, 2, 3},
        {0, 0, 0},
        {-5, 5, 0},
        {100, -20, 80},
    }

    for _, c := range casos {
        resultado := Sum(c.a, c.b)
        if resultado != c.esperado {
            t.Errorf("Sum(%d, %d) = %d; esperado %d", c.a, c.b, resultado, c.esperado)
        }
    }
}

Al correr go test, podrías ver:

--- RUN   TestMultiSum
--- PASS: TestMultiSum (0.00s)
--- PASS: test (0.00s)
PASS
ok   mi_paquete   0.001s

Si, por ejemplo, cambiamos el primer caso a {1, 2, 4}, el mensaje de error sería:

--- RUN   TestMultiSum
--- FAIL: TestMultiSum (0.00s)
    multi_sum_test.go:18: Error: Sum(1, 2) = 3; esperado 4
--- RUN   TestMultiSum
--- PASS: TestMultiSum (0.00s)
FAIL
ok   mi_paquete   0.001s

Acá hay un detalle importante: la prueba sigue ejecutándose para los casos siguientes después de reportar un error con t.Errorf. Si preferís detenerte al primer fallo, podés usar t.Fatal (o t.FailNow). t.Errorf es ideal cuando querés acumular fallos.

Subpruebas con t.Run

Organizando pruebas relacionadas

Cuando tenés muchos casos de prueba o querés agrupar escenarios relacionados, t.Run es una herramienta excelente. Permite crear un contexto de prueba con un nombre legible, lo que hace que la salida de go test sea más fácil de entender.

También posibilita la paralelización: podés llamar a t.Parallel dentro de un subtest para que ese subconjunto de pruebas se ejecute en paralelo con otros subtests.

Un ejemplo práctico

Supongamos que queremos probar una función IsEven que indica si un número es par.

// mi_paquete/even_test.go
package mi_paquete

import "testing"

func IsEven(n int) bool {
    return n%2 == 0
}

func TestIsEven(t *testing.T) {
    casos := []struct {
        n       int
        esperado bool
    }{
        {2, true},
        {3, false},
        {0, true},
        {-4, true},
        {-7, false},
    }

    for _, c := range casos {
        t.Run(c.nombre(), func(t *testing.T) {
            // Nota: necesitamos un nombre legible.
            resultado := IsEven(c.n)
            if resultado != c.esperado {
                t.Errorf("IsEven(%d) = %v; esperado %v", c.n, resultado, c.esperado)
            }
        })
    }
}

Observá que estamos usando t.Run dentro de un for. Cada subprueba recibe un nuevo *testing.T, lo que te permite llamar a t.Parallel si querés acelerar aún más la ejecución.

Al correr go test, puedes ver una salida estructurada como:

--- RUN   TestIsEven
--- RUN   TestIsEven/nombre1
--- PASS: TestIsEven/nombre1 (0.00s)
--- RUN   TestIsEven/nombre2
--- PASS: TestIsEven/nombre2 (0.00s)
--- PASS: TestIsEven (0.00s)
--- PASS: test (0.00s)
PASS
ok   mi_paquete   0.001s

Los nombres que ves en la salida son generados automáticamente por la forma en que llamamos a t.Run. Si querés nombres más amigables, podés construirlos manualmente:

for _, c := range casos {
    nombre := fmt.Sprintf("Case_n=%d_expected=%v", c.n, c.esperado)
    t.Run(nombre, func(t *testing.T) { ... })
}

Uso avanzado: subpruebas paralelas

Las subpruebas son perfectas para paralelizar trabajo independiente. Por ejemplo, si tenés docenas de valores de entrada, podés lanzarlos a diferentes goroutines.

func TestIsEven_Parallel(t *testing.T) {
    casos := []struct{ n, esperado int }{…}
    for _, c := range casos {
        c := c // crear una copia para la closure
        t.Run(c.n, func(t *testing.T) {
            t.Parallel()
            if IsEven(c.n) != (c.esperado == 1) {
                t.Errorf("...")
            }
        })
    }
}

Cuando ejecutás la prueba, verás muchos --- PASS simultáneos, indicando que están corriendo en paralelo.

Un ejemplo completo: probando un pequeño servicio HTTP

Ahora, pongamos todo junto en un ejemplo realista: un pequeño handler HTTP que devuelve un saludo. Escribiremos la implementación y luego una batería de pruebas que cubra casos normales, errores y subpruebas.

Implementación

// handlers/saludo.go
package handlers

import (
    "fmt"
    "net/http"
)

func Saludo(w http.ResponseWriter, r *http.Request) {
    nombre := r.URL.Query().Get("nombre")
    if nombre == "" {
        nombre = "mundo"
    }
    fmt.Fprintf(w, "¡Hola, %s!", nombre)
}

Pruebas unitarias

// handlers/saludo_test.go
package handlers

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestSaludo_ParamDefault(t *testing.T) {
    req, err := http.NewRequest("GET", "/?nombre=juan", nil)
    if err != nil {
        t.Fatal(err)
    }
    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(Saludo)
    handler.ServeHTTP(rr, req)

    if status := rr.Code; status != http.StatusOK {
        t.Errorf("código de estado incorrecto: got %v, want %v",
            status, http.StatusOK)
    }

    esperado := "¡Hola, juan!"
    if rr.Body.String() != esperado {
        t.Errorf("cuerpo incorrecto: got '%s' want '%s'",
            rr.Body.String(), esperado)
    }
}

func TestSaludo_ParamSinNombre(t *testing.T) {
    req, err := http.NewRequest("GET", "/", nil)
    if err != nil {
        t.Fatal(err)
    }
    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(Saludo)
    handler.ServeHTTP(rr, req)

    esperado := "¡Hola, mundo!"
    if rr.Body.String() != esperado {
        t.Errorf("cuerpo incorrecto: got '%s' want '%s'", rr.Body.String(), esperado)
    }
}

func TestSaludo_Subtests(t *testing.T) {
    casos := []struct {
        nombre   string
        query    string
        esperado string
    }{
        {"con nombre", "?nombre=ana", "¡Hola, ana!"},
        {"sin nombre", "", "¡Hola, mundo!"},
        {"nombre vacío", "?nombre=", "¡Hola, mundo!"},
    }

    for _, c := range casos {
        t.Run(c.nombre, func(t *testing.T) {
            req, err := http.NewRequest("GET", c.query, nil)
            if err != nil {
                t.Fatal(err)
            }
            rr := httptest.NewRecorder()
            handler := http.HandlerFunc(Saludo)
            handler.ServeHTTP(rr, req)

            if rr.Body.String() != c.esperado {
                t.Errorf("cuerpo incorrecto: got '%s' want '%s'", rr.Body.String(), c.esperado)
            }
        })
    }
}

Salida esperada

Ejecutando go test ./handlers podrías ver algo similar a:

--- RUN   TestSaludo_ParamDefault
--- PASS: TestSaludo_ParamDefault (0.00s)
--- RUN   TestSaludo_ParamSinNombre
--- PASS: TestSaludo_ParamSinNombre (0.00s)
--- RUN   TestSaludo_Subtests
--- RUN   TestSaludo_Subtests/con nombre
--- PASS: TestSaludo_Subtests/con nombre (0.00s)
--- RUN   TestSaludo_Subtests/sin nombre
--- PASS: TestSaludo_Subtests/sin nombre (0.00s)
--- RUN   TestSaludo_Subtests/nombre vacío
--- PASS: TestSaludo_Subtests/nombre vacío (0.00s)
--- PASS: TestSaludo_Subtests (0.00s)
--- PASS: test (0.00s)
PASS
ok   handlers  0.002s

Si alguno de los subtests falla, verás un FAIL con los mensajes de error que provienen de t.Errorf.

Buenas prácticas, errores comunes y consejos

1. Naming consistente

Los nombres de los Test* deben describir claramente qué se está probando. Usá TestFuncion_Caso o TestFuncion_Subtest. Esto hace que go test -v liste cada prueba de forma legible.

2. Evitá datos de prueba compartidos

Cuando usás variables globales en el paquete que estás probando, necesitás tomar precauciones (usar t.Parallel o limpiar el estado entre pruebas). Una buena práctica es mantener el código bajo prueba puro y libre de efectos secundarios.

3. Usá t.Parallel para independencia

Si cada subprueba trabaja con su propio conjunto de datos, llamá a t.Parallel dentro de ellas. Esto mejora el rendimiento al permitir que las pruebas se ejecuten en paralelo.

4. Comprobá errores con t.Fatal cuando sea apropiado

Cuando un error detiene el curso normal de una prueba (por ejemplo, al instanciar un cliente, al leer un archivo), t.Fatal detiene la prueba inmediatamente y marca el resto como omitido. t.Errorf es mejor cuando querés acumular fallos.

5. Evitá t.Log excesivo

Si bien t.Log puede ser útil para depurar, un exceso de logs en la salida estándar puede ocultar información relevante. Solo registralo cuando realmente necesites un trace.

6. Usá subpruebas para parámetros de entrada

El enfoque de tabla de datos con t.Run es más mantenible que escribir una prueba separada por cada valor. La iteración dentro de un bucle es buena, pero envolver cada caso en un subtest provee un nombre más claro y paralelización.

7. Manejá nil y valores vacíos

Siempre pensá en los casos límite: requests con query strings vacíos, slices de longitud cero, punteros nil. Una prueba que los cubra previene regresión.

8. Usá testing.Short() para pruebas largas

Para CI, podés ejecutar go test -short. Dentro de las pruebas, llamá a t.SkipNow() si un caso es muy costoso para ser parte del conjunto normal.

9. Mantené los archivos de prueba alineados con el código

Si reestructurás el paquete, recordá mover o renombrar los archivos _test.go correspondientes. El go test los detectará automáticamente.

10. Correr pruebas en modo verbose

Cuando investigás un fallo, go test -v muestra cada subprueba con su nombre, lo que ayuda a aislar el problema.

Conclusión

El paquete de testing nativo de Go es simple pero flexible. Con solo tres conceptos — funciones Test*, t.Errorf y t.Run — podés escribir pruebas robustas, bien organizadas y que se ejecutan rápido.

Recapitulando lo que aprendimos:

  • Las pruebas van en archivos _test.go y cualquier función Test* es ejecutada automáticamente.
  • Usá t.Errorf para reportar fallos sin detener la prueba, y t.Fatal cuando un error es fatal para esa prueba.
  • t.Run permite crear subpruebas, facilitar la lectura de la salida y habilitar la paralelización con t.Parallel.
  • Los subtests son perfectos para iterar sobre casos de prueba y darles nombres claros.
  • Una buena salida esperada, obtenida con go test, te da una visión clara del estado de los tests.

Si seguís estas prácticas y las integrás en tu flujo de trabajo diario, vas a escribir pruebas más confiables y vas a reducir significativamente el tiempo que pasás depurando fallos. Así que poné en práctica estos patrones en tu próximo proyecto en Go, y verás cómo el código se vuelve más seguro y mantenible.

¡Feliz testing!


Referencias: Documentación del paquete testing de Go (https://pkg.go.dev/testing), Guía de estilo de Go (https://github.com/golang/go/wiki/CodeReviewComments).

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario