1. Introducción a las pruebas unitarias

Las pruebas unitarias se refieren a verificar y validar la unidad más pequeña que se puede probar en un programa, como una función o un método en el lenguaje Go. Las pruebas unitarias aseguran que el código funcione como se espera y permiten a los desarrolladores realizar cambios en el código sin romper accidentalmente la funcionalidad existente.

En un proyecto de Golang, la importancia de las pruebas unitarias es innegable. En primer lugar, puede mejorar la calidad del código, brindando a los desarrolladores más confianza para realizar cambios en el código. En segundo lugar, las pruebas unitarias pueden servir como documentación para el código, explicando su comportamiento esperado. Además, ejecutar pruebas unitarias automáticamente en un entorno de integración continua puede descubrir rápidamente errores recién introducidos, mejorando así la estabilidad del software.

2. Realización de pruebas básicas usando el paquete testing

La biblioteca estándar del lenguaje Go incluye el paquete testing, que proporciona herramientas y funcionalidades para escribir y ejecutar pruebas.

2.1 Crear tu primer caso de prueba

Para escribir una función de prueba, debes crear un archivo con el sufijo _test.go. Por ejemplo, si tu archivo de código fuente se llama calculator.go, tu archivo de prueba debería llamarse calculator_test.go.

A continuación, es el momento de crear la función de prueba. Una función de prueba necesita importar el paquete testing y seguir un formato específico. Aquí tienes un ejemplo sencillo:

// calculator_test.go
package calculator

import (
	"testing"
	"fmt"
)

// Probar la función de suma
func TestAdd(t *testing.T) {
	resultado := Add(1, 2)
	esperado := 3

	if resultado != esperado {
		t.Errorf("Esperado %v, pero se obtuvo %v", esperado, resultado)
	}
}

En este ejemplo, TestAdd es una función de prueba que prueba una función imaginaria Add. Si el resultado de la función Add coincide con el resultado esperado, la prueba pasará; de lo contrario, se llamará a t.Errorf para registrar la información sobre el fallo de la prueba.

2.2 Entender las reglas de nomenclatura y la firma de las funciones de prueba

Las funciones de prueba deben comenzar con Test, seguido de cualquier cadena que no sea minúscula, y su único parámetro debe ser un puntero a testing.T. Como se muestra en el ejemplo, TestAdd sigue las reglas de nomenclatura y firma correctas.

2.3 Ejecutar casos de prueba

Puedes ejecutar tus casos de prueba utilizando la herramienta de línea de comandos. Para un caso de prueba específico, ejecuta el siguiente comando:

go test -v // Ejecuta pruebas en el directorio actual y muestra una salida detallada

Si deseas ejecutar un caso de prueba específico, puedes usar el indicador -run seguido de una expresión regular:

go test -v -run TestAdd // Ejecutar solo la función de prueba TestAdd

El comando go test encontrará automáticamente todos los archivos _test.go y ejecutará cada función de prueba que cumpla con los criterios. Si todas las pruebas pasan, verás un mensaje similar a PASS en la línea de comandos; si alguna prueba falla, verás FAIL junto con el mensaje de error correspondiente.

3. Escribir casos de prueba

3.1 Reportar errores utilizando t.Errorf y t.Fatalf

En el lenguaje Go, el marco de pruebas proporciona varios métodos para reportar errores. Las dos funciones más comúnmente utilizadas son Errorf y Fatalf, ambas son métodos del objeto testing.T. Errorf se utiliza para reportar errores en la prueba pero no detiene el caso de prueba actual, mientras que Fatalf detiene la prueba actual inmediatamente después de reportar un error. Es importante elegir el método apropiado en función de los requisitos de la prueba.

Ejemplo de uso de Errorf:

func TestAdd(t *testing.T) {
    obtenido := Add(1, 2)
    esperado := 3
    if obtenido != esperado {
        t.Errorf("Add(1, 2) = %d; esperado %d", obtenido, esperado)
    }
}

Si deseas detener la prueba inmediatamente cuando se detecta un error, puedes usar Fatalf:

func TestSubtract(t *testing.T) {
    obtenido := Subtract(5, 3)
    if obtenido != 2 {
        t.Fatalf("Subtract(5, 3) = %d; esperado 2", obtenido)
    }
}

En general, si el error causaría que el código posterior no se ejecute correctamente o el fallo de la prueba se puede confirmar por adelantado, se recomienda utilizar Fatalf. De lo contrario, se recomienda utilizar Errorf para obtener un resultado de prueba más completo.

3.2 Organización de subpruebas y ejecución de subpruebas

En Go, podemos utilizar t.Run para organizar subpruebas, lo que nos ayuda a escribir código de prueba de una manera más estructurada. Las subpruebas pueden tener su propio Setup y Teardown y pueden ejecutarse individualmente, lo que proporciona una gran flexibilidad. Esto es especialmente útil para realizar pruebas complejas o pruebas parametrizadas.

Ejemplo de uso de subprueba t.Run:

func TestMultiply(t *testing.T) {
    testcases := []struct {
        name           string
        a, b, expected int
    }{
        {"2x3", 2, 3, 6},
        {"-1x-1", -1, -1, 1},
        {"0x4", 0, 4, 0},
    }

    for _, tc := range testcases {
        t.Run(tc.name, func(t *testing.T) {
            if got := Multiply(tc.a, tc.b); got != tc.expected {
                t.Errorf("Multiply(%d, %d) = %d; quiero %d", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

Si queremos ejecutar la subprueba llamada "2x3" individualmente, podemos ejecutar el siguiente comando en la línea de comandos:

go test -run TestMultiply/2x3

Por favor, ten en cuenta que los nombres de las subpruebas distinguen entre mayúsculas y minúsculas.

4. Trabajo preparatorio antes y después de las pruebas

4.1 Configuración y limpieza

Al llevar a cabo pruebas, a menudo necesitamos preparar algún estado inicial para las pruebas (como conexión a base de datos, creación de archivos, etc.), y de manera similar, necesitamos realizar un trabajo de limpieza después de que las pruebas se completen. En Go, generalmente realizamos la Configuración y Limpieza directamente en las funciones de prueba, y la función t.Cleanup nos proporciona la capacidad de registrar funciones de devolución de llamada de limpieza.

Aquí tienes un ejemplo simple:

func TestDatabase(t *testing.T) {
    db, err := SetupDatabase()
    if err != nil {
        t.Fatalf("error en la configuración: %v", err)
    }

    // Registra una devolución de llamada de limpieza para garantizar que la conexión a la base de datos se cierre cuando la prueba esté terminada
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("error al cerrar la base de datos: %v", err)
        }
    })

    // Realiza la prueba...
}

En la función TestDatabase, primero llamamos a la función SetupDatabase para configurar el entorno de prueba. Luego, utilizamos t.Cleanup() para registrar una función que se llamará después de que la prueba se complete para realizar el trabajo de limpieza, en este ejemplo, es cerrar la conexión a la base de datos. De esta manera, podemos garantizar que los recursos se liberen correctamente independientemente de si la prueba tiene éxito o falla.

5. Mejorar la eficiencia de las pruebas

Mejorar la eficiencia de las pruebas puede ayudarnos a iterar el desarrollo más rápidamente, descubrir rápidamente problemas y garantizar la calidad del código. A continuación, discutiremos la cobertura de pruebas, las pruebas impulsadas por tablas y el uso de simulaciones para mejorar la eficiencia de las pruebas.

5.1 Cobertura de pruebas y herramientas relacionadas

La herramienta go test proporciona una característica de cobertura de pruebas muy útil, que nos ayuda a comprender qué partes del código son cubiertas por los casos de prueba, descubriendo así áreas de código que no están cubiertas por los casos de prueba.

Usando el comando go test -cover, puedes ver el porcentaje actual de cobertura de pruebas:

go test -cover

Si deseas comprender de forma más detallada qué líneas de código se ejecutaron y cuáles no, puedes utilizar el parámetro -coverprofile, que genera un archivo de datos de cobertura. Luego, puedes usar el comando go tool cover para generar un informe detallado de cobertura de pruebas.

go test -coverprofile=coverage.out
go tool cover -html=coverage.out

El comando anterior abrirá un informe web que muestra visualmente qué líneas de código están cubiertas por las pruebas y cuáles no. El color verde representa código cubierto por las pruebas, mientras que el rojo representa líneas de código no cubiertas.

5.2 Uso de Mocks

En pruebas, a menudo nos encontramos con escenarios donde necesitamos simular dependencias externas. Los mocks pueden ayudarnos a simular estas dependencias, eliminando la necesidad de depender de servicios externos específicos o recursos en el entorno de pruebas.

En la comunidad Go, existen muchas herramientas de mock, como testify/mock y gomock. Estas herramientas suelen proporcionar una serie de APIs para crear y usar objetos mock.

Aquí tienes un ejemplo básico de uso de testify/mock. Lo primero que debes hacer es definir una interfaz y su versión de mock:

type DataService interface {
    FetchData() (int, error)
}

type MockDataService struct {
    mock.Mock
}

func (m *MockDataService) FetchData() (int, error) {
    args := m.Called()
    return args.Int(0), args.Error(1)
}

En las pruebas, podemos usar MockDataService para reemplazar el servicio de datos real:

func TestAlgo(t *testing.T) {
    mockDataSvc := new(MockDataService)
    mockDataSvc.On("FetchData").Return(42, nil) // Configurando el comportamiento esperado

    resultado, err := mockDataSvc.FetchData() // Usando el objeto mock
    assert.NoError(t, err)
    assert.Equal(t, 42, resultado)

    mockDataSvc.AssertExpectations(t) // Verificando si se produjo el comportamiento esperado
}

A través del enfoque anterior, podemos evitar depender o aislar servicios externos, llamadas a base de datos, etc. en las pruebas. Esto puede acelerar la ejecución de las pruebas y hacer que nuestras pruebas sean más estables y confiables.

6. Técnicas Avanzadas de Pruebas

Después de dominar los conceptos básicos de las pruebas unitarias en Go, podemos explorar algunas técnicas de pruebas más avanzadas, las cuales ayudan a construir software más robusto y mejorar la eficiencia de las pruebas.

6.1 Pruebas de Funciones Privadas

En Golang, las funciones privadas suelen referirse a funciones no exportadas, es decir, funciones cuyos nombres comienzan con una letra minúscula. Por lo general, preferimos probar interfaces públicas, ya que reflejan la usabilidad del código. Sin embargo, hay casos en los que también tiene sentido probar directamente las funciones privadas, como cuando la función privada tiene lógica compleja y es llamada por múltiples funciones públicas.

Probar funciones privadas difiere de probar funciones públicas porque no se puede acceder a ellas desde fuera del paquete. Una técnica común es escribir el código de prueba en el mismo paquete, lo que permite acceder a la función privada.

Aquí tienes un ejemplo sencillo:

// calculator.go
package calculator

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

El archivo de prueba correspondiente es el siguiente:

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    esperado := 4
    actual := add(2, 2)
    if actual != esperado {
        t.Errorf("esperado %d, obtuvo %d", esperado, actual)
    }
}

Al colocar el archivo de prueba en el mismo paquete, podemos probar directamente la función add.

6.2 Patrones Comunes de Pruebas y Mejores Prácticas

Las pruebas unitarias en Go tienen algunos patrones comunes que facilitan el trabajo de pruebas y ayudan a mantener la claridad y la mantenibilidad del código.

  1. Pruebas Basadas en Tablas

    Las pruebas basadas en tablas son un método de organizar las entradas de prueba y las salidas esperadas. Al definir un conjunto de casos de prueba y luego recorrerlos para realizar pruebas, este método facilita mucho la adición de nuevos casos de prueba y también hace que el código sea más fácil de leer y mantener.

    // calculator_test.go
    package calculator

    import "testing"

    func TestAddTableDriven(t *testing.T) {
        var tests = []struct {
            a, b   int
            want   int
        }{
            {1, 2, 3},
            {2, 2, 4},
            {5, -1, 4},
        }

        for _, tt := range tests {
            nombrePrueba := fmt.Sprintf("%d,%d", tt.a, tt.b)
            t.Run(nombrePrueba, func(t *testing.T) {
                ans := add(tt.a, tt.b)
                if tt.want != ans {
                    t.Errorf("obtuvo %d, esperaba %d", ans, tt.want)
                }
            })
        }
    }
  1. Uso de Mocks para Pruebas

    El mock es una técnica de prueba que implica reemplazar dependencias para probar varias partes de la funcionalidad. En Golang, las interfaces son la forma principal de implementar mocks. Al usar interfaces, se puede crear una implementación de mock y luego usarla en las pruebas.