Pruebas unitarias en Go: Estructura y mejores prácticas
Pruebas en Go desde lo básico hasta patrones avanzados
El paquete de prueba integrado de Go proporciona un marco poderoso y minimalista para escribir pruebas unitarias sin dependencias externas. Aquí están los fundamentos de las pruebas, la estructura del proyecto y los patrones avanzados para construir aplicaciones Go confiables.

¿Por qué las pruebas importan en Go
La filosofía de Go enfatiza la simplicidad y la confiabilidad. La biblioteca estándar incluye el paquete testing, haciendo que las pruebas unitarias sean ciudadanos de primera clase en el ecosistema de Go. El código Go bien probado mejora la mantenibilidad, detecta errores temprano y proporciona documentación a través de ejemplos. Si eres nuevo en Go, consulta nuestro Hoja de trucos de Go para una referencia rápida de los fundamentos del lenguaje.
Beneficios clave de las pruebas en Go:
- Soporte integrado: No se requieren marcos externos
- Ejecución rápida: Ejecución concurrente de pruebas por defecto
- Sintaxis simple: Poco código de plantilla
- Herramientas ricas: Informes de cobertura, benchmarks y perfilado
- Amigable con CI/CD: Integración fácil con pipelines automatizados
Estructura del proyecto para pruebas en Go
Las pruebas en Go viven junto a tu código de producción con una convención clara de nombres:
myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│ ├── helper.go
│ └── helper_test.go
└── models/
├── user.go
└── user_test.go
Convenios clave:
- Los archivos de prueba terminan con
_test.go - Las pruebas están en el mismo paquete que el código (o usan el sufijo
_testpara pruebas de caja negra) - Cada archivo de origen puede tener un archivo de prueba correspondiente
Enfoques de pruebas por paquete
Pruebas de caja blanca (mismo paquete):
package calculator
import "testing"
// Puede acceder a funciones y variables no exportadas
Pruebas de caja negra (paquete externo):
package calculator_test
import (
"testing"
"myproject/calculator"
)
// Solo puede acceder a funciones exportadas (recomendado para APIs públicas)
Estructura básica de las pruebas
Cada función de prueba sigue este patrón:
package calculator
import "testing"
// La función de prueba debe comenzar con "Test"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
Métodos de testing.T:
t.Error()/t.Errorf(): Marca la prueba como fallida pero continúat.Fatal()/t.Fatalf(): Marca la prueba como fallida y detiene inmediatamentet.Log()/t.Logf(): Registra salida (solo mostrada con la bandera-v)t.Skip()/t.Skipf(): Salta la pruebat.Parallel(): Ejecuta la prueba en paralelo con otras pruebas en paralelo
Pruebas basadas en tablas: La forma idiomática de Go
Las pruebas basadas en tablas son el enfoque idiomático de Go para probar múltiples escenarios. Con Generics en Go, también puedes crear ayudantes de prueba seguros de tipo que funcionen con diferentes tipos de datos:
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"adición", 2, 3, "+", 5, false},
{"sustracción", 5, 3, "-", 2, false},
{"multiplicación", 4, 3, "*", 12, false},
{"división", 10, 2, "/", 5, false},
{"división por cero", 10, 0, "/", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
Ventajas:
- Una sola función de prueba para múltiples escenarios
- Fácil de agregar nuevos casos de prueba
- Documentación clara del comportamiento esperado
- Mejor organización y mantenibilidad de las pruebas
Ejecutar pruebas
Comandos básicos
# Ejecutar pruebas en el directorio actual
go test
# Ejecutar pruebas con salida detallada
go test -v
# Ejecutar pruebas en todos los subdirectorios
go test ./...
# Ejecutar una prueba específica
go test -run TestAdd
# Ejecutar pruebas que coinciden con un patrón
go test -run TestCalculate/adición
# Ejecutar pruebas en paralelo (por defecto es GOMAXPROCS)
go test -parallel 4
# Ejecutar pruebas con tiempo de espera
go test -timeout 30s
Cobertura de pruebas
# Ejecutar pruebas con cobertura
go test -cover
# Generar perfil de cobertura
go test -coverprofile=coverage.out
# Ver cobertura en el navegador
go tool cover -html=coverage.out
# Mostrar cobertura por función
go tool cover -func=coverage.out
# Establecer modo de cobertura (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out
Banderas útiles
-short: Ejecutar pruebas marcadas conif testing.Short()-race: Habilitar el detector de carrera (encuentra problemas de acceso concurrente)-cpu: Especificar valores de GOMAXPROCS-count n: Ejecutar cada prueba n veces-failfast: Detener en la primera falla de prueba
Ayudantes de prueba y configuración/limpieza
Funciones ayudantes
Marcar funciones ayudantes con t.Helper() para mejorar el informe de errores:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // Esta línea se reporta como el llamador
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestMath(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5) // La línea de error apunta aquí
}
Configuración y limpieza
func TestMain(m *testing.M) {
// Código de configuración aquí
setup()
// Ejecutar pruebas
code := m.Run()
// Código de limpieza aquí
teardown()
os.Exit(code)
}
Casos de prueba
func setupTestCase(t *testing.T) func(t *testing.T) {
t.Log("setup test case")
return func(t *testing.T) {
t.Log("teardown test case")
}
}
func TestSomething(t *testing.T) {
teardown := setupTestCase(t)
defer teardown(t)
// Código de prueba aquí
}
Mocking y Inyección de dependencias
Mocking basado en interfaces
Cuando pruebas código que interactúa con bases de datos, usar interfaces facilita la creación de implementaciones de mock. Si estás trabajando con PostgreSQL en Go, consulta nuestra comparación de ORMs en Go para elegir la biblioteca de base de datos adecuada con buena testabilidad.
// Código de producción
type Database interface {
GetUser(id int) (*User, error)
}
type UserService struct {
db Database
}
func (s *UserService) GetUserName(id int) (string, error) {
user, err := s.db.GetUser(id)
if err != nil {
return "", err
}
return user.Name, nil
}
// Código de prueba
type MockDatabase struct {
users map[int]*User
}
func (m *MockDatabase) GetUser(id int) (*User, error) {
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, errors.New("usuario no encontrado")
}
func TestGetUserName(t *testing.T) {
mockDB := &MockDatabase{
users: map[int]*User{
1: {ID: 1, Name: "Alice"},
},
}
service := &UserService{db: mockDB}
name, err := service.GetUserName(1)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if name != "Alice" {
t.Errorf("got %s, want Alice", name)
}
}
Bibliotecas de prueba populares
Testify
La biblioteca de prueba más popular en Go para afirmaciones y mocks:
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestWithTestify(t *testing.T) {
result := Add(2, 3)
assert.Equal(t, 5, result, "deberían ser iguales")
assert.NotNil(t, result)
}
// Ejemplo de mock
type MockDB struct {
mock.Mock
}
func (m *MockDB) GetUser(id int) (*User, error) {
args := m.Called(id)
return args.Get(0).(*User), args.Error(1)
}
Otras herramientas
- gomock: Marco de mocking de Google con generación de código
- httptest: Biblioteca estándar para probar manejadores HTTP
- testcontainers-go: Pruebas de integración con contenedores Docker
- ginkgo/gomega: Marco de prueba estilo BDD
Cuando pruebas integraciones con servicios externos como modelos de IA, necesitarás mockear o stubear esas dependencias. Por ejemplo, si estás usando Ollama en Go, considera crear envolturas de interfaz para hacer tu código más testable.
Pruebas de benchmark
Go incluye soporte integrado para benchmarks:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// Ejecutar benchmarks
// go test -bench=. -benchmem
La salida muestra iteraciones por segundo y asignaciones de memoria.
Mejores prácticas
- Escribe pruebas basadas en tablas: Usa el patrón de slice de structs para múltiples casos de prueba
- Usa t.Run para subpruebas: Mejor organización y se pueden ejecutar subpruebas selectivamente
- Prueba primero las funciones exportadas: Enfócate en el comportamiento de la API pública
- Mantén las pruebas simples: Cada prueba debe verificar una sola cosa
- Usa nombres significativos para las pruebas: Describe qué se está probando y el resultado esperado
- No pruebes detalles de implementación: Prueba el comportamiento, no los internos
- Usa interfaces para dependencias: Facilita el mocking
- Busca alta cobertura, pero calidad sobre cantidad: La cobertura del 100% no significa que esté libre de errores
- Ejecuta pruebas con la bandera -race: Detecta problemas de concurrencia temprano
- Usa TestMain para configuración costosa: Evita repetir la configuración en cada prueba
Ejemplo: Suite de pruebas completa
package user
import (
"errors"
"testing"
)
type User struct {
ID int
Name string
Email string
}
func ValidateUser(u *User) error {
if u.Name == "" {
return errors.New("el nombre no puede estar vacío")
}
if u.Email == "" {
return errors.New("el correo electrónico no puede estar vacío")
}
return nil
}
// Archivo de prueba: user_test.go
func TestValidateUser(t *testing.T) {
tests := []struct {
name string
user *User
wantErr bool
errMsg string
}{
{
name: "usuario válido",
user: &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
wantErr: false,
},
{
name: "nombre vacío",
user: &User{ID: 1, Name: "", Email: "alice@example.com"},
wantErr: true,
errMsg: "el nombre no puede estar vacío",
},
{
name: "correo electrónico vacío",
user: &User{ID: 1, Name: "Alice", Email: ""},
wantErr: true,
errMsg: "el correo electrónico no puede estar vacío",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && err.Error() != tt.errMsg {
t.Errorf("ValidateUser() mensaje de error = %v, want %v", err.Error(), tt.errMsg)
}
})
}
}
Enlaces útiles
- Documentación oficial del paquete de prueba de Go
- Blog de Go: Pruebas basadas en tablas
- Repositorio de GitHub de Testify
- Documentación de GoMock
- Aprende Go con pruebas
- Herramienta de cobertura de código de Go
- Hoja de trucos de Go
- Comparación de ORMs para PostgreSQL en Go: GORM vs Ent vs Bun vs sqlc
- SDKs de Go para Ollama - comparación con ejemplos
- Aplicaciones CLI en Go con Cobra & Viper
- Generics en Go: Casos de uso y patrones
Conclusión
El marco de pruebas de Go proporciona todo lo necesario para pruebas unitarias completas con mínima configuración. Siguiendo los idiomas de Go como pruebas basadas en tablas, usando interfaces para mocking y aprovechando las herramientas integradas, puedes crear suites de pruebas mantenibles y confiables que crecen con tu base de código.
Estas prácticas de prueba se aplican a todos los tipos de aplicaciones en Go, desde servicios web hasta aplicaciones CLI construidas con Cobra & Viper. Probar herramientas de línea de comandos requiere patrones similares con un enfoque adicional en la prueba de entrada/salida y el análisis de banderas.
Comienza con pruebas simples, añade gradualmente cobertura y recuerda que probar es una inversión en la calidad del código y la confianza del desarrollador. El énfasis de la comunidad de Go en las pruebas hace más fácil mantener proyectos a largo plazo y colaborar eficazmente con compañeros.