Patrones de prueba para go-mssqldb

Este artículo trata patrones para escribir pruebas de integración contra SQL Server al utilizar el go-mssqldb controlador.

Elige el tipo de prueba adecuado

Prefiero probar contra una instancia real de SQL Server. Un contenedor de SQL Server (mediante testcontainers-go, Docker Compose o un servicio de CI) detecta errores de sintaxis de SQL, incompatibilidades de tipos y el comportamiento de las transacciones que los mocks no pueden detectar. Este enfoque es el mismo que utiliza el go-mssqldb controlador para su propio conjunto de pruebas. En Windows, LocalDB es una alternativa ligera que no requiere Docker.

Recurra a go-sqlmock solo para pruebas unitarias rápidas del ciclo interno, donde el tiempo de arranque del contenedor predominaría sobre la ejecución. Por ejemplo, úsalo para probar la lógica de reintentos o el mapeo de resultados a nivel de aplicación.

Tipo de prueba Úselo para Evítalo cuando
Pruebas de integración con testcontainers-go Ejecuciones y suites de CI reproducibles que necesitan una instancia real de SQL Server sin tener que gestionar infraestructura compartida. Pruebas rápidas en bucle interno donde el tiempo de arranque del contenedor dominaría la ejecución.
Pruebas de integración contra un SQL Server compartido o local Procedimientos almacenados, objetos de esquema, comportamiento de transacciones, tablas temporales y comportamiento de controladores de extremo a extremo. Las pruebas necesitan infraestructura aislada o deben ejecutarse de forma consistente en CI sin dependencias externas.
Pruebas unitarias con go-sqlmock Lógica de nivel de aplicación como bucles de reintento, mapeo de resultados y ramificación de errores cuando no necesitas validar la sintaxis SQL. Necesitas verificar el comportamiento del driver, la sintaxis de SQL frente a SQL Server o la semántica de las transacciones.

Configuración de la base de datos de pruebas

Utiliza variables de entorno para configurar la cadena de conexión de prueba para pruebas de integración. Este enfoque mantiene las credenciales fuera del código fuente y facilita la integración CI/CD:

package myapp_test

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

    _ "github.com/microsoft/go-mssqldb"
)

var testDB *sql.DB

func TestMain(m *testing.M) {
    connString := os.Getenv("TEST_MSSQL_URL")
    if connString == "" {
        panic("TEST_MSSQL_URL is not set")
    }

    var err error
    testDB, err = sql.Open("sqlserver", connString)
    if err != nil {
        panic("Failed to open test DB: " + err.Error())
    }
    defer testDB.Close()

    if err = testDB.Ping(); err != nil {
        panic("Failed to connect to test DB: " + err.Error())
    }

    os.Exit(m.Run())
}

Establezca la variable de entorno antes de ejecutar pruebas:

export TEST_MSSQL_URL="sqlserver://<user>:<password>@<server>:1433?database=<database>&encrypt=true&TrustServerCertificate=true"
go test ./...

Usar transacciones para el aislamiento de pruebas

Envuelve cada prueba en una transacción y vuelve atrás al final. Este enfoque mantiene la base de datos limpia entre pruebas:

func TestInsertDepartment(t *testing.T) {
    tx, err := testDB.Begin()
    if err != nil {
        t.Fatal(err)
    }
    defer tx.Rollback() // Always roll back - never commits

    _, err = tx.Exec(
        "INSERT INTO HumanResources.Department (Name, GroupName) VALUES (@p1, @p2)",
        sql.Named("p1", "TestDept"),
        sql.Named("p2", "TestGroup"))
    if err != nil {
        t.Fatal(err)
    }

    var count int
    err = tx.QueryRow("SELECT COUNT(*) FROM HumanResources.Department WHERE Name = @p1",
        sql.Named("p1", "TestDept")).Scan(&count)
    if err != nil {
        t.Fatal(err)
    }

    if count != 1 {
        t.Errorf("Expected 1 row, got %d", count)
    }
}

Este patrón funciona mejor para pruebas que ejercen código de repositorio dentro de un único límite de transacción. No es adecuado para código que abre y confirma sus propias transacciones internamente, ni para pruebas que necesitan validar el comportamiento en varias conexiones.

SQL Server en Docker para CI/CD

Utiliza un contenedor Linux de SQL Server para pruebas de integración en pipelines de CI:

# GitHub Actions example
services:
  mssql:
    image: mcr.microsoft.com/mssql/server:2025-latest
    env:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "<password>"
    ports:
      - 1433:1433

Luego establece la cadena de conexión de prueba:

env:
    TEST_MSSQL_URL: "sqlserver://sa:<password>@localhost:1433?database=AdventureWorks2025"

Omitir las pruebas cuando no haya ninguna base de datos disponible

Para proyectos donde una instancia de SQL Server no siempre esté disponible, salta las pruebas de integración con elegancia:

func TestQueryEmployees(t *testing.T) {
    if os.Getenv("TEST_MSSQL_URL") == "" {
        t.Skip("TEST_MSSQL_URL not set, skipping integration test")
    }
    // ... test body
}

Ayuda para la prueba: crear y soltar tablas

Crea una función auxiliar que configure una tabla de pruebas y la limpie después de la prueba:

func withTestTable(t *testing.T, db *sql.DB, fn func()) {
    t.Helper()

    _, err := db.Exec(`
        IF OBJECT_ID('dbo.TestItems', 'U') IS NOT NULL DROP TABLE dbo.TestItems;
        CREATE TABLE dbo.TestItems (Id INT IDENTITY PRIMARY KEY, Name NVARCHAR(50));
    `)
    if err != nil {
        t.Fatal("Setup failed:", err)
    }

    defer func() {
        db.Exec("DROP TABLE IF EXISTS dbo.TestItems")
    }()

    fn()
}

Pruebas unitarias con go-sqlmock

Si el tiempo de arranque del contenedor es demasiado lento para tu bucle interno de desarrollo, go-sqlmock crea una memoria *sql.DB interna que devuelve resultados predefinidos. Úsala para lógica de nivel de aplicación (bucles de reintento, mapeo de resultados, ramificación de errores) donde no necesitas validar la sintaxis SQL frente a un servidor real:

go get github.com/DATA-DOG/go-sqlmock

Simular una consulta

Configura las consultas esperadas y verifica que la aplicación gestiona correctamente los resultados:

Note

sqlmock.ExpectQuery trata su entrada como una expresión regular, no como una cadena SQL simple. Caracteres como (, ), +, y . deben ser evadidos para coincidir literalmente con ellos en texto SQL. En los literales de cadena de Go, estas secuencias de escape aparecen duplicadas (por ejemplo, \\( para un ( literal en la expresión regular).

package myapp_test

import (
    "testing"
    "github.com/DATA-DOG/go-sqlmock"
)

func TestGetEmployee(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    rows := sqlmock.NewRows([]string{"BusinessEntityID", "Name", "Location"}).
        AddRow(1, "Alice", "Canada")

    mock.ExpectQuery("SELECT TOP \\(1\\) BusinessEntityID, FirstName \\+ ' ' \\+ LastName AS Name, CountryRegionName AS Location FROM Sales\\.vSalesPerson WHERE BusinessEntityID = @p1").
        WithArgs(1).
        WillReturnRows(rows)

    emp, err := GetEmployee(db, 1)
    if err != nil {
        t.Fatal(err)
    }
    if emp.Name != "Alice" {
        t.Errorf("Expected Alice, got %s", emp.Name)
    }

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("Unmet expectations: %v", err)
    }
}

Simular un error

Devuelve un error del mock para probar las rutas de manejo de errores:

func TestGetEmployeeNotFound(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    mock.ExpectQuery("SELECT").
        WithArgs(999).
        WillReturnError(sql.ErrNoRows)

    _, err = GetEmployee(db, 999)
    if err == nil {
        t.Error("Expected error for nonexistent employee")
    }

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("Unmet expectations: %v", err)
    }
}

Tip

Diseña tus funciones de acceso a datos para que las acepten *sql.DB (o una interfaz) como parámetro en lugar de usar un global a nivel de paquete. Este patrón facilita sustituir las bases de datos go-sqlmock en las pruebas.

Pruebas de integración con testcontainers-go

testcontainers-goCrea un contenedor de SQL Server por suite de pruebas y lo desmonta automáticamente. Este enfoque se recomienda para la mayoría de las suites de pruebas porque valida el comportamiento real de SQL Server sin gestionar la infraestructura compartida:

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/mssql

Para ejecutar este ejemplo localmente:

  1. Asegúrate de que Docker Desktop u otro motor local de Docker esté funcionando.
  2. Guarda el examen en un _test.go archivo dentro de tu módulo.
  3. Ejecuta go test -run TestWithContainer -v ./... desde la raíz del módulo.

Utiliza este enfoque para la mayor parte de tu suite de pruebas. El inicio del contenedor añade unos segundos, pero obtienes una validación real de SQL Server que detecta problemas y simulacros fallados.

package myapp_test

import (
    "context"
    "database/sql"
    "testing"

    _ "github.com/microsoft/go-mssqldb"
    "github.com/testcontainers/testcontainers-go/modules/mssql"
)

func TestWithContainer(t *testing.T) {
    ctx := context.Background()

    container, err := mssql.Run(ctx,
        "mcr.microsoft.com/mssql/server:2025-latest",
        mssql.WithAcceptEULA(),
        mssql.WithPassword("<password>"))
    if err != nil {
        t.Fatal(err)
    }
    defer container.Terminate(ctx)

    connStr, err := container.ConnectionString(ctx)
    if err != nil {
        t.Fatal(err)
    }

    db, err := sql.Open("sqlserver", connStr)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // Create schema.
    _, err = db.ExecContext(ctx, `
        CREATE TABLE dbo.TestDepartments (
            Id INT IDENTITY PRIMARY KEY,
            Name NVARCHAR(50),
            GroupName NVARCHAR(50)
        )`)
    if err != nil {
        t.Fatal(err)
    }

    // Run tests against the real database.
    _, err = db.ExecContext(ctx,
        "INSERT INTO dbo.TestDepartments (Name, GroupName) VALUES (@p1, @p2)",
        sql.Named("p1", "Data Science"),
        sql.Named("p2", "Research and Development"))
    if err != nil {
        t.Fatal(err)
    }

    var count int
    err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.TestDepartments").Scan(&count)
    if err != nil {
        t.Fatal(err)
    }
    if count != 1 {
        t.Errorf("Expected 1 row, got %d", count)
    }
}

Testcontainers: error de certificado X.509

Con Go 1.23 y posteriores, podrías ver este error al conectarte a un contenedor de SQL Server:

x509: negative serial number

Go 1.23 aplica estrictamente la RFC 5280, y el certificado autofirmado generado por SQL Server en Docker usa un número de serie negativo. Como los contenedores de prueba no necesitan TLS de nivel de producción, añade TrustServerCertificate=true o encrypt=disable a la cadena de conexión de prueba:

connStr, err := container.ConnectionString(ctx, "TrustServerCertificate=true")
if err != nil {
    t.Fatal(err)
}

Precaución

Úsalo TrustServerCertificate=true o encrypt=disable solo en entornos de prueba. Para conexiones de producción, utilice la validación adecuada de certificados. Consulta Cifrado y certificados.

Para obtener más información, consulte Solución de problemas.

Pruebas comparativas de rendimiento mediante pruebas.B

Utiliza el marco de benchmarks integrado de Go para medir el rendimiento de operaciones de bases de datos:

func BenchmarkInsert(b *testing.B) {
    connString := os.Getenv("TEST_MSSQL_URL")
    if connString == "" {
        b.Skip("TEST_MSSQL_URL not set")
    }

    db, err := sql.Open("sqlserver", connString)
    if err != nil {
        b.Fatalf("open database: %v", err)
    }
    defer db.Close()

    ctx := context.Background()
    db.ExecContext(ctx, `
        IF OBJECT_ID('dbo.BenchItems', 'U') IS NOT NULL DROP TABLE dbo.BenchItems;
        CREATE TABLE dbo.BenchItems (Id INT IDENTITY PRIMARY KEY, Name NVARCHAR(100))`)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        db.ExecContext(ctx,
            "INSERT INTO dbo.BenchItems (Name) VALUES (@p1)",
            sql.Named("p1", fmt.Sprintf("item-%d", i)))
    }

    b.StopTimer()
    db.ExecContext(ctx, "DROP TABLE IF EXISTS dbo.BenchItems")
}

Ejecutar pruebas de rendimiento:

go test -bench=BenchmarkInsert -benchmem -count=5

Flujo de trabajo completo de Acciones de GitHub

Este ejemplo muestra una pipeline completa de CI que configura un contenedor de SQL Server, crea un esquema de pruebas y ejecuta tanto pruebas unitarias como de integración:

name: Go SQL Server Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mssql:
        image: mcr.microsoft.com/mssql/server:2025-latest
        env:
          ACCEPT_EULA: "Y"
          MSSQL_SA_PASSWORD: "<password>"
        ports:
          - 1433:1433
        options: >-
          --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<password>' -C -Q 'SELECT 1'"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: "1.22"

      - name: Create test schema
        run: |
          /opt/mssql-tools18/bin/sqlcmd \
            -S localhost -U sa -P "<password>" -C \
                        -Q "CREATE DATABASE AdventureWorks2025"

          /opt/mssql-tools18/bin/sqlcmd \
            -S localhost -U sa -P "<password>" -C \
                        -d AdventureWorks2025 \
            -i ./schema/setup.sql

      - name: Run unit tests
        run: go test -v -short ./...

      - name: Run integration tests
        env:
                    TEST_MSSQL_URL: "sqlserver://sa:<password>@localhost:1433?database=AdventureWorks2025"
        run: go test -v -race -count=1 ./...

Rutas de error de prueba y lógica de reintentos

Prueba que tu aplicación gestiona correctamente los errores transitorios y los intentos:

func TestRetryOnTransientError(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // First call fails with a transient error.
    mock.ExpectQuery("SELECT").WillReturnError(fmt.Errorf("mssql: timeout"))

    // Second call succeeds.
    rows := sqlmock.NewRows([]string{"Id"}).AddRow(1)
    mock.ExpectQuery("SELECT").WillReturnRows(rows)

    result, err := queryWithRetry(db, "SELECT ProductID FROM Production.Product WHERE ProductID = @p1", 1)
    if err != nil {
        t.Fatalf("Expected success after retry, got: %v", err)
    }
    if result != 1 {
        t.Errorf("Expected 1, got %d", result)
    }
}

Comparación de estrategias de prueba

Strategy Velocidad Real DB Dependencias Más adecuado para
testcontainers-go Medio (segundos) Docker La mayoría de los conjuntos de pruebas (recomendados).
Docker en CI Promedio (segundos) Docker Canales de CI/CD con Acciones de GitHub.
Reversión de transacciones Rápido (ms) SQL Server Pruebas de integración en una base de datos compartida.
go-sqlmock Rápido (ms) No Ninguno Pruebas unitarias de bucle interno solo para lógica de aplicaciones.
t.Skip con variable de entorno Instantánea No Ninguno Degradación gradual cuando no hay base de datos disponible.