Cuando testeamos lógica de negocio, necesitamos aislar la unidad bajo prueba de sus dependencias externas (bases de datos, APIs, sistemas de archivos). Esto se logra mediante Test Doubles. Un mock es una implementación controlada de una interfaz que nos permite verificar no solo qué devuelve una función, sino cómo fue llamada.
En Go, esto es posible gracias a que las interfaces se implementan de forma implícita. Si un struct define los métodos de una interfaz, Go lo considera una implementación válida sin necesidad de declarar explícitamente que “implementa” dicha interfaz.
Un mock manual es un struct que escribimos nosotros mismos para satisfacer una interfaz, permitiéndonos registrar llamadas o forzar errores. Es la opción ideal cuando trabajas con interfaces pequeñas (de 1 a 3 métodos) o cuando quieres mantener el test en el mismo paquete para tener acceso a campos privados. Por el contrario, los generadores automáticos como mockery (que genera mocks basados en tus interfaces) o gomock (que permite definir expectativas complejas mediante un DSL) son preferibles cuando las interfaces son extensas o cambian con mucha frecuencia. Si te equivocas en la implementación manual de una interfaz grande, el compilador te avisará, pero el mantenimiento de ese código se convierte rápidamente en una carga de deuda técnica.
package example_test
import (
"errors"
"testing"
)
// --- Código de Producción ---
// PaymentGateway es la dependencia que queremos aislar.
type PaymentGateway interface {
Process(amount float64) error
}
// OrderService es nuestra unidad bajo prueba (SUT).
type OrderService struct {
gateway PaymentGateway
}
// Checkout procesa un pedido. Si el monto es inválido, falla antes de tocar el gateway.
func (s *OrderService) Checkout(amount float64) error {
if amount <= 0 {
return errors.New("monto inválido")
}
return s.gateway.Process(amount)
}
// --- Mocks para Tests ---
// ManualMockPaymentGateway es un "spy" y "stub" manual.
// Inyectamos una función en el struct para definir el comportamiento
// de forma dinámica en cada caso de prueba.
type ManualMockPaymentGateway struct {
// ProcessFunc nos permite cambiar el retorno según el test.
ProcessFunc func(amount float64) error
// Calls actúa como un "spy" para verificar qué se le pasó al método.
Calls []float64
}
// Process implementa la interfaz PaymentGateway.
func (m *ManualMockPaymentGateway) Process(amount float64) error {
m.Calls = append(m.Calls, amount)
if m.ProcessFunc != nil {
return m.ProcessFunc(amount)
}
return nil
}
// --- Tests ---
func TestOrderService_Checkout(t *testing.T) {
t.Run("debe procesar un pago exitoso", func(t *testing.T) {
// Arrange
mock := &ManualMockPaymentGateway{
ProcessFunc: func(amount float64) error {
return nil // Simula éxito
},
}
service := &OrderService{gateway: mock}
// Act
err := service.Checkout(150.50)
// Assert
if err != nil {
t.Fatalf("esperado sin error, obtenido: %v", err)
}
if len(mock.Calls) != 1 || mock.Calls[0] != 150.50 {
t.Errorf("el mock no registró la llamada correcta. Got: %v", mock.Calls)
}
})
t.Run("debe fallar si el gateway devuelve error", func(t *testing.T) {
// Arrange
expectedErr := errors.New("saldo insuficiente")
mock := &ManualMockPaymentGateway{
ProcessFunc: func(amount float64) error {
return expectedErr
},
}
service := &OrderService{gateway: mock}
// Act
err := service.Checkout(50.0)
// Assert
if !errors.Is(err, expectedErr) {
t.Errorf("se esperaba el error %v, se obtuvo %v", expectedErr, err)
}
})
}
Análisis de la implementación
En el ejemplo, hemos implementado ManualMockPaymentGateway siguiendo un patrón de diseño robusto para testing: la inyección de comportamiento.
Fíjate en la definición de ProcessFunc. En lugar de hardcodear un valor de retorno en el struct, definimos un campo de tipo func. Esto es crucial: permite que en el test de “éxito” el mock devuelva nil, y en el test de “error” devuelva un errors.New(...), todo utilizando la misma estructura.
El campo Calls convierte al mock en un spy. No solo devuelve valores, sino que “espía” y guarda los argumentos recibidos. En la línea if len(mock.Calls) != 1 || mock.Calls[0] != 150.50, estamos verificando que la lógica de OrderService no haya alterado el monto antes de enviarlo al gateway, una aserción fundamental para evitar bugs financieros.
Si este PaymentGateway tuviera 15 métodos adicionales, escribir este struct manualmente sería una pérdida de tiempo. Ahí es donde usarías mockery para generar el archivo mock_payment_gateway.go y simplemente importarías el mock generado.
El error frecuente
Un error clásico al usar mocks manuales es la contaminación de estado entre tests. Si declaras el mock como una variable global o lo reutilizas sin reiniciarlo, los datos acumulados en el slice Calls de un test afectarán la lógica del siguiente.
// ERROR: El mock se comparte entre tests
var sharedMock = &ManualMockPaymentGateway{}
func TestA(t *testing.T) {
sharedMock.ProcessFunc = func(a float64) error { return nil }
sharedMock.Process(10)
// sharedMock.Calls ahora tiene [10]
}
func TestB(t *testing.T) {
// Este test fallará si espera que Calls esté vacío,
// porque heredó el [10] del test anterior.
sharedMock.Process(20)
if len(sharedMock.Calls) != 1 {
t.Error("El mock está contaminado")
}
}
Para evitar esto, instanciar siempre un nuevo mock dentro de cada t.Run o resetear explícitamente sus campos.
N° 157