tutoriales.com

Pruebas de Integración en Go: Asegurando la Robustez de tus Aplicaciones con Bases de Datos y Servicios Externos

Este tutorial te guiará a través de la implementación de pruebas de integración en Go. Cubrirá las mejores prácticas para probar componentes que interactúan con servicios externos como bases de datos, utilizando herramientas como Docker para entornos de prueba aislados y el paquete `testing` de Go.

Intermedio25 min de lectura6 views
Reportar error

Las pruebas son una parte crucial del ciclo de vida del desarrollo de software. Si bien las pruebas unitarias nos ayudan a validar componentes individuales de forma aislada, las pruebas de integración son esenciales para asegurar que diferentes partes de nuestra aplicación, y sus interacciones con servicios externos, funcionen correctamente en conjunto. En Go, podemos escribir pruebas de integración efectivas que garanticen la robustez y fiabilidad de nuestras aplicaciones.

🎯 ¿Qué son las Pruebas de Integración y por qué son Importantes?

Las pruebas de integración se centran en verificar la interacción entre diferentes módulos o servicios de una aplicación. A diferencia de las pruebas unitarias, que aíslan componentes y simulan dependencias (mocking), las pruebas de integración prueban la interacción real con dependencias externas como bases de datos, sistemas de archivos, APIs de terceros o servicios de mensajería.

💡 Beneficios Clave de las Pruebas de Integración:

  • Detección temprana de errores: Revelan problemas en la comunicación entre componentes o con servicios externos.
  • Confianza en la integración: Aseguran que el sistema funciona como un todo coherente.
  • Validación de contratos: Verifican que los componentes cumplen con sus interfaces y contratos de comunicación.
  • Cobertura realista: Simulan escenarios de uso más cercanos a la producción que las pruebas unitarias.
⚠️ Advertencia: Las pruebas de integración suelen ser más lentas y complejas de escribir y mantener que las pruebas unitarias debido a su dependencia de entornos externos. Es crucial equilibrar su uso.

🛠️ Entendiendo el Entorno de Pruebas en Go

Go tiene un paquete de testing integrado que facilita la escritura de pruebas, tanto unitarias como de integración. Para las pruebas de integración, necesitamos un entorno donde nuestras dependencias externas puedan ser accesibles.

Convenciones de Nomenclatura

  • Los archivos de prueba deben terminar en _test.go.
  • Las funciones de prueba deben comenzar con Test y aceptar un argumento *testing.T.
  • Para pruebas de integración que pueden ser más lentas, es común usar el marcador t.Parallel() para que Go pueda ejecutarlas en paralelo.

Ejemplo Básico de Estructura de Proyecto

Consideremos una aplicación Go que interactúa con una base de datos PostgreSQL.

mi_app/
├── main.go
├── internal/
│   ├── repository/
│   │   ├── user.go
│   │   └── user_test.go
│   └── service/
│       ├── user.go
│       └── user_test.go
└── go.mod
└── go.sum

En user_test.go dentro de repository, tendremos pruebas de integración para la capa de persistencia.


🚀 Configurando un Entorno de Pruebas Aislado con Docker

Ejecutar pruebas de integración contra una base de datos real es fundamental. Sin embargo, no queremos afectar nuestra base de datos de desarrollo ni depender de una configuración manual compleja. Docker es una herramienta excelente para crear entornos de base de datos efímeros y aislados para nuestras pruebas.

Paso 1: Docker Compose para un Entorno de DB

Crearemos un archivo docker-compose.test.yml en la raíz de nuestro proyecto:

version: '3.8'
services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpassword
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
      interval: 5s
      timeout: 5s
      retries: 5

Este archivo define un servicio PostgreSQL que estará disponible en el puerto 5432 con credenciales y una base de datos específicas para pruebas.

Paso 2: Helper de Docker para las Pruebas de Go

Podemos crear un test_helper.go o una función de configuración global para nuestras pruebas que inicie y detenga el contenedor de Docker. Sin embargo, una práctica más robusta es usar el paquete testcontainers-go o ejecutar docker-compose directamente antes de las pruebas.

Para simplicidad y control, a menudo se usa una función TestMain en Go para gestionar el ciclo de vida del entorno de pruebas.

// main_test.go (o en un paquete de pruebas dedicado)
package repository_test

import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"testing"

	_ "github.com/lib/pq"
	"github.com/ory/dockertest/v3"
	"github.com/ory/dockertest/v3/docker"
)

var db *sql.DB

func TestMain(m *testing.M) {
	// Iniciar Docker Compose para la base de datos de prueba
	pool, err := dockertest.NewPool("")
	if err != nil {
		log.Fatalf("No se pudo conectar a Docker: %s", err)
	}

	resource, err := pool.RunWithOptions(&dockertest.RunOptions{
		Repository: "postgres",
		Tag:        "15-alpine",
		Env: []string{
			"POSTGRES_USER=testuser",
			"POSTGRES_PASSWORD=testpassword",
			"POSTGRES_DB=testdb",
		},
		Mounts: []string{
			// Opcional: montar scripts de inicialización de la DB
			// "./sql/init_test.sql:/docker-entrypoint-initdb.d/init.sql",
		},
	},
	func(config *docker.HostConfig) {
		// Set AutoRemove to true so that stopped container goes away by itself.
		config.AutoRemove = true
		config.RestartPolicy = docker.RestartPolicy{
			Name: "no",
		}
	})
	if err != nil {
		log.Fatalf("No se pudo iniciar el contenedor PostgreSQL: %s", err)
	}

	// Esperar a que la base de datos esté lista
	connectionString := fmt.Sprintf("host=localhost port=%s user=testuser password=testpassword dbname=testdb sslmode=disable", resource.Get};HostPort("5432/tcp"))
	err = pool.Retry(func() error {
		db, err = sql.Open("postgres", connectionString)
		if err != nil {
			return err
		}
		return db.Ping()
	})
	if err != nil {
		log.Fatalf("No se pudo conectar a la base de datos: %s", err)
	}

	// Ejecutar todas las pruebas
	exitCode := m.Run()

	// Limpiar el contenedor después de las pruebas
	if err := pool.Purge(resource); err != nil {
		log.Fatalf("No se pudo purgar el recurso: %s", err)
	}

	os.Exit(exitCode)
}

// Aquí irían las funciones de limpieza de datos para cada test
func setupTestDB(t *testing.T) {
	_, err := db.Exec(`
		DROP TABLE IF EXISTS users;
		CREATE TABLE users (
			id SERIAL PRIMARY KEY,
			name VARCHAR(255) NOT NULL,
			email VARCHAR(255) UNIQUE NOT NULL
		);
	`)
	if err != nil {
		t.Fatalf("Fallo al configurar la tabla de prueba: %v", err)
	}
}

func teardownTestDB(t *testing.T) {
	_, err := db.Exec(`DROP TABLE IF EXISTS users;`)
	if err != nil {
		t.Fatalf("Fallo al limpiar la tabla de prueba: %v", err)
	}
}
💡 Consejo: Para `dockertest`, asegúrate de instalarlo con `go get github.com/ory/dockertest/v3`.

📝 Escribiendo Pruebas de Integración con database/sql

Ahora que tenemos un entorno de base de datos, podemos escribir pruebas que interactúen con ella. Supongamos que tenemos un UserRepository con métodos para interactuar con la tabla users.

// internal/repository/user.go
package repository

import (
	"database/sql"
	"fmt"
)

type User struct {
	ID    int
	Name  string
	Email string
}

type UserRepository struct {
	DB *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
	return &UserRepository{DB: db}
}

func (r *UserRepository) CreateUser(user User) (*User, error) {
	stmt, err := r.DB.Prepare("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id")
	if err != nil {
		return nil, fmt.Errorf("fallo al preparar la declaración: %w", err)
	}
	defer stmt.Close()

	err = stmt.QueryRow(user.Name, user.Email).Scan(&user.ID)
	if err != nil {
		return nil, fmt.Errorf("fallo al insertar usuario: %w", err)
	}
	return &user, nil
}

func (r *UserRepository) GetUserByID(id int) (*User, error) {
	user := &User{}
	err := r.DB.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id).Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, nil // Usuario no encontrado
		}
		return nil, fmt.Errorf("fallo al obtener usuario por ID: %w", err)
	}
	return user, nil
}

// Otros métodos de CRUD...

Pruebas de Integración para UserRepository

// internal/repository/user_test.go
package repository_test

import (
	"testing"

	"mi_app/internal/repository"

	"github.com/stretchr/testify/assert"
)

func TestCreateUser_Integration(t *testing.T) {
	setupTestDB(t) // Limpiar y configurar la tabla antes de cada test
	defer teardownTestDB(t) // Limpiar después de cada test

	repo := repository.NewUserRepository(db)

	newUser := repository.User{
		Name:  "Alice",
		Email: "alice@example.com",
	}

	createdUser, err := repo.CreateUser(newUser)

	assert.NoError(t, err)
	assert.NotNil(t, createdUser)
	assert.NotZero(t, createdUser.ID)
	assert.Equal(t, "Alice", createdUser.Name)
	assert.Equal(t, "alice@example.com", createdUser.Email)

	// Verificar que el usuario realmente está en la base de datos
	fetchedUser, err := repo.GetUserByID(createdUser.ID)
	assert.NoError(t, err)
	assert.NotNil(t, fetchedUser)
	assert.Equal(t, createdUser, *fetchedUser)
}

func TestGetUserByID_Integration(t *testing.T) {
	setupTestDB(t)
	defer teardownTestDB(t)

	repo := repository.NewUserRepository(db)

	// Insertar un usuario directamente para la prueba
	_, err := db.Exec("INSERT INTO users (name, email) VALUES ($1, $2)", "Bob", "bob@example.com")
	assert.NoError(t, err)

	var bobID int
	err = db.QueryRow("SELECT id FROM users WHERE email = $1", "bob@example.com").Scan(&bobID)
	assert.NoError(t, err)

	// Caso de éxito: obtener un usuario existente
	fetchedUser, err := repo.GetUserByID(bobID)
	assert.NoError(t, err)
	assert.NotNil(t, fetchedUser)
	assert.Equal(t, bobID, fetchedUser.ID)
	assert.Equal(t, "Bob", fetchedUser.Name)
	assert.Equal(t, "bob@example.com", fetchedUser.Email)

	// Caso de fallo: obtener un usuario que no existe
	nonExistentUser, err := repo.GetUserByID(9999)
	assert.NoError(t, err) // sql.ErrNoRows se convierte a nil por nuestro repo
	assert.Nil(t, nonExistentUser)
}

func TestCreateUser_DuplicateEmail_Integration(t *testing.T) {
	setupTestDB(t)
	defer teardownTestDB(t)

	repo := repository.NewUserRepository(db)

	user1 := repository.User{Name: "Charlie", Email: "charlie@example.com"}
	_, err := repo.CreateUser(user1)
	assert.NoError(t, err)

	user2 := repository.User{Name: "David", Email: "charlie@example.com"}
	_, err = repo.CreateUser(user2)
	assert.Error(t, err) // Esperamos un error por email duplicado
	assert.Contains(t, err.Error(), "duplicate key value violates unique constraint")
}
🔥 Importante: La limpieza de la base de datos (`setupTestDB` y `teardownTestDB`) es crucial para asegurar que cada prueba se ejecute en un estado conocido y aislado. Esto evita la contaminación entre pruebas.

🌐 Probando Interacciones con Servicios Externos (APIs REST)

Además de las bases de datos, nuestras aplicaciones Go a menudo interactúan con otras APIs REST. Para pruebas de integración, queremos probar la comunicación real. Sin embargo, no queremos depender de la disponibilidad y el estado de una API externa real en todo momento.

Estrategias para APIs Externas:

  1. Contenedores Docker: Si la API externa tiene una imagen Docker disponible, podemos usarla de manera similar a cómo usamos PostgreSQL.
  2. Servicios de Mocking Locales: Herramientas como WireMock o MockServer (que también pueden ejecutarse en Docker) pueden simular el comportamiento de una API externa con respuestas predefinidas.
  3. Realismo limitado: Para APIs muy estables, se podría usar el endpoint de staging, pero esto introduce latencia y dependencia de red.

Ejemplo con un Servidor HTTP Falso (para demostración)

Aunque en integración real usaríamos Docker o un mock server, para ilustrar el concepto, podemos simular un servidor HTTP simple para nuestras pruebas. Esto es más cercano a las pruebas unitarias con un test double, pero puede ser útil si la API es muy simple o si solo queremos probar el cliente HTTP de Go.

// internal/client/external_api.go
package client

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type Post struct {
	UserID int    `json:"userId"`
	ID     int    `json:"id"`
	Title  string `json:"title"`
	Body   string `json:"body"`
}

type ExternalAPIClient struct {
	BaseURL string
	HTTPClient *http.Client
}

func NewExternalAPIClient(baseURL string) *ExternalAPIClient {
	return &ExternalAPIClient{
		BaseURL: baseURL,
		HTTPClient: &http.Client{},
	}
}

func (c *ExternalAPIClient) GetPost(id int) (*Post, error) {
	resp, err := c.HTTPClient.Get(fmt.Sprintf("%s/posts/%d", c.BaseURL, id))
	if err != nil {
		return nil, fmt.Errorf("fallo en la solicitud GET: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("estado de respuesta inesperado: %d", resp.StatusCode)
	}

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("fallo al leer la respuesta: %w", err)
	}

	var post Post
	err = json.Unmarshal(bodyBytes, &post)
	if err != nil {
		return nil, fmt.Errorf("fallo al decodificar JSON: %w", err)
	}

	return &post, nil
}

Prueba de Integración para ExternalAPIClient

// internal/client/external_api_test.go
package client_test

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"mi_app/internal/client"

	"github.com/stretchr/testify/assert"
)

func TestGetPost_Integration(t *testing.T) {
	// Configurar un servidor HTTP de prueba
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "/posts/1", r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(client.Post{
			UserID: 1,
			ID:     1,
			Title:  "sunt aut facere",
			Body:   "quia et suscipit",
		})
	}))
	defer server.Close() // Asegúrate de cerrar el servidor al finalizar la prueba

	// Usar la URL del servidor de prueba para nuestro cliente
	apiClient := client.NewExternalAPIClient(server.URL)

	post, err := apiClient.GetPost(1)

	assert.NoError(t, err)
	assert.NotNil(t, post)
	assert.Equal(t, 1, post.ID)
	assert.Equal(t, "sunt aut facere", post.Title)
}

func TestGetPost_NotFound_Integration(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "/posts/999", r.URL.Path)
		w.WriteHeader(http.StatusNotFound)
		fmt.Fprint(w, "{}") // Respuesta vacía o JSON de error
	}))
	defer server.Close()

	apiClient := client.NewExternalAPIClient(server.URL)

	_, err := apiClient.GetPost(999)

	assert.Error(t, err)
	assert.Contains(t, err.Error(), "estado de respuesta inesperado: 404")
}
📌 Nota: `httptest.NewServer` es excelente para simular un servidor HTTP para pruebas unitarias y de integración de bajo nivel del cliente HTTP. Para APIs más complejas, un mock server externo o un contenedor Docker de la API real sería más apropiado.

✅ Buenas Prácticas y Consideraciones Finales

Escribir pruebas de integración efectivas requiere una planificación cuidadosa.

⚙️ Estrategias de Limpieza de Datos

  • Truncar tablas: Después de cada prueba o suite de pruebas, vacía las tablas relevantes. Es más rápido que recrear la base de datos entera.
  • Transacciones: Envuelve cada prueba en una transacción y haz ROLLBACK al final. Esto asegura que la base de datos se restablezca a su estado original. Esto es ideal para repositorios pero más complejo con ORMs o capas de servicio.
  • Esquema de prueba separado: Asegúrate de que las pruebas siempre usen una base de datos o esquema diferente al de desarrollo/producción.

💨 Rendimiento de las Pruebas

  • t.Parallel(): Usa t.Parallel() en tus funciones de prueba para que Go pueda ejecutar pruebas de forma concurrente, mejorando el tiempo total de ejecución.
  • Etiquetado de pruebas: A veces, querrás ejecutar solo pruebas unitarias o solo de integración. Puedes usar la bandera go test -short y comprobar testing.Short() en tus tests para saltar pruebas lentas.
  • Entorno dedicado: Un servidor de integración continua (CI) debe tener la capacidad de aprovisionar y limpiar los recursos externos necesarios para las pruebas de integración.

⚖️ Equilibrio entre Pruebas Unitarias y de Integración

  • Pirámide de pruebas: La mayoría de las pruebas deben ser unitarias, seguidas de menos pruebas de integración, y un número aún menor de pruebas end-to-end.
Pruebas End-to-End Pocas (Lentas y Costosas) Pruebas de Integración Moderadas Pruebas Unitarias Muchas (Rápidas y Baratas) MAYOR COMPLEJIDAD MAYOR COSTE Pirámide de Pruebas de Software

Herramientas Útiles

  • dockertest: Como se mostró, una biblioteca Go para gestionar contenedores Docker en pruebas.
  • testcontainers-go: Una alternativa a dockertest con una API moderna y fluida.
  • stretchr/testify: Una suite de aserciones (assert) que mejora la legibilidad de las pruebas.
🔥 Importante: La automatización es clave. Integra la ejecución de tus pruebas de integración en tu pipeline de CI/CD para detectar regresiones rápidamente.

Pasos para ejecutar las pruebas:

  1. Asegúrate de tener Docker corriendo en tu sistema.
  2. Instala las dependencias de Go: go mod tidy
  3. Ejecuta las pruebas de integración con:
go test -v ./...
O, para ejecutar solo las pruebas del paquete `repository_test`:
go test -v ./internal/repository/...

Este tutorial ha cubierto los fundamentos para establecer un entorno de pruebas de integración robusto en Go, especialmente para interacciones con bases de datos y servicios externos. Al aplicar estas técnicas, puedes construir aplicaciones Go más confiables y mantenibles.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!

Pruebas de Integración en Go: Asegurando la Robustez de tus Aplicaciones con Bases de Datos y Servicios Externos | tutoriales.com