La testabilidad en Go no es una característica del runtime, sino una consecuencia directa de cómo defines los tipos y sus relaciones. Para escribir código que no dependa del sistema de archivos, de una base de datos o de una API externa, debes diseñar basándote en interfaces pequeñas.
Una interfaz en Go es simplemente un contrato de comportamiento. Si una función acepta un tipo concreto, como *os.File, está atada a la implementación real y a sus efectos secundarios (escribir en disco). Si, en cambio, acepta io.Reader, la función no sabe —ni le importa— si los datos vienen de un archivo, de una variable en memoria o de un socket de red; solo le importa que el tipo tenga un método Read.
Este diseño permite aplicar la inyección de dependencias. Cuando necesitas probar una lógica de negocio, no quieres que tus tests toquen el disco duro (lo cual es lento y puede dejar basura); prefieres inyectar un “test double” (como un mock o un stub) que implemente ese contrato de forma controlada en memoria. Si diseñas interfaces masivas, con decenas de métodos, el mocking se vuelve una pesadilla: tu objeto de prueba tendrá que implementar todos esos métodos, aunque el código que estás probando solo use uno.
Si te equivocas y dependes de tipos concretos en lugar de interfaces, tus tests se convertirán en tests de integración pesados, difíciles de ejecutar en entornos de CI/CD y propensos a fallar por causas ajenas a tu lógica (como falta de permisos en una carpeta).
package main
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
)
// Storage define un contrato mínimo para guardar datos.
// Al ser una interfaz pequeña, es extremadamente fácil de mockear.
type Storage interface {
Save(name string, data []byte) error
}
// Processor contiene la lógica de negocio.
// Depende de la interfaz Storage, no de una implementación concreta.
type Processor struct {
storage Storage
}
// Process lee datos de un Reader y los guarda en el Storage.
// Nota cómo usamos io.Reader para abstraer la fuente de datos.
func (p *Processor) Process(name string, r io.Reader) error {
data, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("error leyendo datos: %w", err)
}
if len(data) == 0 {
return errors.New("no hay datos para procesar")
}
// Transformación simple de ejemplo: pasar a mayúsculas
processed := bytes.ToUpper(data)
return p.storage.Save(name, processed)
}
// --- Test Doubles ---
// MockStorage es un stub que implementa Storage para pruebas.
// Nos permite verificar qué se intentó guardar sin tocar el disco.
type MockStorage struct {
LastSavedName string
LastSavedData []byte
ReturnError error
SaveCalledOnce bool
}
func (m *MockStorage) Save(name string, data []byte) error {
m.SaveCalledOnce = true
m.LastSavedName = name
m.LastSavedData = data
return m.ReturnError
}
// --- Ejecución (Simulando tests) ---
func main() {
// Escenario 1: Test de éxito con Mock
fmt.Println("--- Escenario 1: Éxito ---")
mock := &MockStorage{}
proc := Processor{storage: mock}
input := strings.NewReader("hola mundo")
err := proc.Process("test.txt", input)
if err != nil {
fmt.Printf("Error inesperado: %v\n", err)
return
}
if mock.SaveCalledOnce && string(mock.LastSavedData) == "HOLA MUNDO" {
fmt.Printf("Éxito: Se guardó correctamente: %s\n", string(mock.LastSavedData))
}
// Escenario 2: Test de error de lógica
fmt.Println("\n--- Escenario 2: Error de contenido ---")
inputVacio := strings.NewReader("")
err = proc.Process("vacio.txt", inputVacio)
fmt.Printf("Error esperado (vacío): %v\n", err)
// Escenario 3: Test de error en la dependencia (Storage)
fmt.Println("\n--- Escenario 3: Error de dependencia ---")
mockErr := &MockStorage{ReturnError: errors.New("error de disco")}
procErr := Processor{storage: mockErr}
err = procErr.Process("fallo.txt", strings.NewReader("datos"))
fmt.Printf("Error esperado (disco): %v\n", err)
}
Desglose del código
En el ejemplo, la clave es la interfaz Storage. Al definirla con un único método Save, hemos creado un contrato extremadamente estrecho.
Fíjate en la estructura Processor. Su campo storage es de tipo Storage (la interfaz), no un puntero a una implementación de base de datos o de sistema de archivos. Esto es lo que permite que en la función main, cuando configuramos el mock, el compilador acepte a MockStorage sin quejarse. MockStorage no tiene ninguna relación estructural con un sistema de archivos, pero como implementa Save(string, []byte) error, “es” un Storage para el compilador.
En la línea donde ejecutamos proc.Process(name, input), estamos pasando un strings.NewReader. Gracias a que el método Process pide un io.Reader, podemos inyectar este objeto de memoria en lugar de tener que crear un archivo real en el disco para probar la lógica de conversión a mayúsculas.
Finalmente, el MockStorage actúa como un observador. Usamos los campos LastSavedName y LastSavedData para inspeccionar si la lógica de Processor hizo su trabajo correctamente (la transformación a mayúsculas) antes de intentar persistir los datos.
El error frecuente
Un error común es intentar mockear tipos concretos que no son interfaces. Por ejemplo, si tu código fuera así:
// MAL: Dependencia de un tipo concreto de la librería estándar
func (p *Processor) Process(f *os.File) error {
// ...
}
Si intentas testear esto, estarás obligado a usar archivos reales en el disco. No puedes pasarle un strings.NewReader a una función que espera un *os.File, porque son tipos distintos y el segundo es una estructura concreta con su propio estado interno. Al intentar “simular” un archivo, terminas creando archivos temporales, gestionando permisos de usuario y limpiando archivos al terminar, lo que convierte un test unitario rápido en un test de integración lento y propenso a fallos por entorno.
Cuando realmente necesites probar la interacción con el sistema de archivos (por ejemplo, para asegurar que los permisos del archivo son correctos), no uses mocks; usa t.TempDir() de la librería testing. Es una forma mucho más limpia de interactuar con el OS real sin ensuciar el sistema de archivos del desarrollador.
N° 156