Mocking idiomático en Go mediante interfaces

En lugar de intentar mockear una librería compleja como sql.DB, lo que hacemos es definir una interfaz que contenga solo los métodos que nuestra lógica necesita. Esto es el mocking idiomático: definir la interfaz en el lado del consumidor. ¿Por qué funciona esto? Porque Go utiliza duck typing implícito: cualquier tipo que implemente los métodos de la interfaz la satisface, sin necesidad de declarar explícitamente que “implementa” dicha interfaz. Debes usar este patrón siempre que tu código dependa de servicios externos, como bases de datos, APIs o sistemas de archivos, para permitir tests unitarios rápidos y determinísticos. Si ignoras esto y diseñas tus estructuras para recibir tipos concretos (como *sql.DB o *http.Client), habrás creado un código acoplado que es imposible de testear de forma aislada, obligándote a realizar tests de integración costosos cada vez que quieras validar una simple regla de negocio.

package main

import (
	"errors"
	"fmt"
)

// User representa nuestro modelo de datos.
type User struct {
	ID   int
	Name string
}

// UserStore es la interfaz que define el comportamiento que nuestro
// servicio necesita. Nota que no es una copia de sql.DB, sino una
// versión mínima de lo que nuestro código realmente consume.
type UserStore interface {
	GetByID(id int) (*User, error)
}

// UserService es nuestra lógica de negocio. Depende de la interfaz,
// no de una implementación concreta. Esto es inyección de dependencias.
type UserService struct {
	store UserStore
}

// GetUserName es el método que queremos testear.
func (s *UserService) GetUserName(id int) (string, error) {
	user, err := s.store.GetByID(id)
	if err != nil {
		return "", err
	}
	if user.Name == "" {
		return "", errors.New("usuario sin nombre")
	}
	return user.Name, nil
}

// --- IMPLEMENTACIONES ---

// PostgresStore es la implementación real que usarás en producción.
type PostgresStore struct {
	// Aquí iría una conexión real a la base de datos
}

func (p *PostgresStore) GetByID(id int) (*User, error) {
	// Simulación de una consulta real a la base de datos.
	fmt.Println("[Real DB] Consultando Postgres...")
	return &User{ID: id, Name: "Usuario Real"}, nil
}

// MockStore es nuestra implementación de prueba.
// Usamos un campo funcional para que cada test pueda definir su propio comportamiento.
type MockStore struct {
	GetByIDFn func(id int) (*User, error)
}

// GetByID delega la llamada a la función definida en el mock.
func (m *MockStore) GetByID(id int) (*User, error) {
	return m.GetByIDFn(id)
}

func main() {
	// 1. Escenario de Producción
	realStore := &PostgresStore{}
	service := UserService{store: realStore}
	
	name, err := service.GetUserName(1)
	fmt.Printf("Producción: Nombre: %s, Error: %v\n", name, err)

	// 2. Escenario de Test: Éxito
	mockSuccess := &MockStore{
		GetByIDFn: func(id int) (*User, error) {
			return &User{ID: id, Name: "Mock Éxito"}, nil
		},
	}
	service.store = mockSuccess
	name, err = service.GetUserName(1)
	fmt.Printf("Test (Éxito): Nombre: %s, Error: %v\n", name, err)

	// 3. Escenario de Test: Error de base de datos
	mockErr := &MockStore{
		GetByIDFn: func(id int) (*User, error) {
			return nil, errors.New("error de conexión")
		},
	}
	service.store = mockErr
	name, err = service.GetUserName(1)
	fmt.Printf("Test (Error): Nombre: %s, Error: %v\n", name, err)
}

Análisis del código

Fíjate en cómo UserService no tiene ni la menor idea de si está hablando con una base de datos Postgres o con un objeto en memoria. Solo sabe que tiene un campo store que cumple con el contrato de UserStore.

En la implementación de MockStore, he utilizado un patrón avanzado: el mock con campos funcionales (GetByIDFn). En lugar de crear un struct diferente para cada caso de error o éxito, definimos un struct único cuyo método GetByID simplemente llama a una función que nosotros le inyectamos en el momento. Esto hace que tus tests sean increíblemente expresivos y te permite definir comportamientos complejos (como errores específicos o retornos de datos distintos) directamente en la línea de código del test, sin necesidad de crear múltiples tipos de mock.

Cuando la interfaz crece y tiene decenas de métodos, crear este mock manual se vuelve tedioso. Es ahí cuando delegas: usa generadores como mockery para crear estas implementaciones automáticamente a partir de tus interfaces. Sin embargo, para la mayoría de los casos de lógica de negocio, el mock manual con funciones es más legible y mantiene el test más cerca de la intención.

El error frecuente

El error más común es intentar inyectar la dependencia de forma concreta en los métodos o en los structs, en lugar de usar la interfaz.

// MAL: Tu código está acoplado a sql.DB.
// Es imposible testear GetUserName sin una base de datos real encendida.
type UserService struct {
    db *sql.DB 
}

func (s *UserService) GetUserName(id int) (string, error) {
    // ...
}

Si haces esto, has perdido la capacidad de hacer un test unitario puro. El desarrollador se ve obligado a usar bases de datos de prueba o contenedores de Docker (Testcontainers), lo que convierte un test de milisegundos en uno de segundos, y hace que la suite de tests sea lenta y frágil ante fallos de red o de infraestructura.

79

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio