Modelli di test per go-mssqldb

Questo articolo tratta i modelli per scrivere test di integrazione su SQL Server durante l'utilizzo del go-mssqldb driver.

Scegli il tipo di test giusto

Preferisco testare contro un'istanza reale di SQL Server. Un container SQL Server (tramite testcontainers-goDocker Compose o un servizio CI) rileva errori di sintassi SQL, disallineamenti di tipo e comportamenti di transazioni che i mock non possono rilevare. Questo approccio è lo stesso che il go-mssqldb driver utilizza per la propria suite di test. Su Windows, LocalDB è un'alternativa leggera che non richiede Docker.

Ripiega su go-sqlmock solo per i test unitari rapidi del ciclo interno, in cui il tempo di avvio del container supererebbe il tempo di esecuzione. Ad esempio, usarlo per testare la logica di ritenti a livello applicativo o la mappatura dei risultati.

Tipo di test Usalo per Evitalo quando
Test di integrazione con testcontainers-go Eseguimenti e suite riproducibili CI che richiedono un'istanza SQL Server reale senza gestire l'infrastruttura condivisa. Test rapidi a ciclo interno in cui il tempo di avvio del container dominerebbe l'esecuzione.
Test di integrazione su un SQL Server condiviso o locale Procedure memorizzate, oggetti dello schema, comportamento delle transazioni, tabelle temporanee e comportamento dei driver end-to-end. I test necessitano di un'infrastruttura isolata o devono essere eseguiti in modo coerente in CI senza dipendenze esterne.
Test unitari con go-sqlmock Logica del livello applicativo come cicli di nuovo tentativo, mappatura dei risultati e diramazione in base agli errori quando non è necessario convalidare la sintassi SQL. Devi verificare il comportamento dei driver, la sintassi SQL rispetto a SQL Server o la semantica delle transazioni.

Configurazione del database di prova

Usa le variabili di ambiente per configurare la stringa di connessione di test per i test di integrazione. Questo approccio tiene le credenziali fuori dal codice sorgente e rende semplice l'integrazione 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())
}

Imposta la variabile di ambiente prima di eseguire i test:

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

Usa le transazioni per l'isolamento dei test

Ingloba ogni test in una transazione e torna indietro alla fine. Questo approccio mantiene il database pulito tra un test e l'altro:

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)
    }
}

Questo modello funziona meglio per test che esercitano codice repository all'interno di un singolo confine di transazione. Non è adatto al codice che apre e conferma internamente le proprie transazioni, né ai test che devono verificare il comportamento attraverso più connessioni.

SQL Server in Docker per la CI/CD

Usa un container Linux SQL Server per i test di integrazione nelle pipeline 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

Quindi imposta la stringa di connessione di test:

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

Saltare i test quando non è disponibile un database

Per progetti in cui un'istanza di SQL Server potrebbe non essere sempre disponibile, saltare con grazia i test di integrazione:

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

Aiuto al test: creare e lasciare tabelle

Crea una funzione helper che configuri un tavolo di test e lo ripulisca dopo il test:

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()
}

Test unitari con go-sqlmock

Se il tempo di avvio del container è troppo lento per il tuo ciclo interno di sviluppo, go-sqlmock si crea un inmemory *sql.DB che restituisce risultati predefiniti. Usalo per la logica a livello applicativo (cicli di ritento, mappatura dei risultati, ramificazione degli errori) dove non devi validare la sintassi SQL contro un server reale:

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

Simula una query

Imposta le query previste e verifica che l'applicazione gestisca correttamente i risultati:

Note

sqlmock.ExpectQuery tratta il suo input come un'espressione regolare, non come una semplice stringa SQL. Caratteri come (, ), + e . devono essere preceduti da un carattere di escape perché vengano interpretati letteralmente nel testo SQL. Nei letterali della stringa Go, questi escape appaiono raddoppiati (ad esempio, \\( per un letterale ( nel regex).

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)
    }
}

Simula un errore

Restituisci un errore dalla mock per testare i percorsi di gestione degli errori:

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

Progetta le funzioni di accesso ai dati per accettare *sql.DB (o un'interfaccia) come parametro invece di usare un globale a livello di pacchetto. Questo schema rende semplice sostituire go-sqlmock i database nei test.

Test di integrazione con testcontainers-go

testcontainers-goavvia un container SQL Server per ogni suite di test e lo smonta automaticamente. Questo approccio è raccomandato per la maggior parte delle suite di test perché valida il comportamento reale di SQL Server senza gestire l'infrastruttura condivisa:

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

Per eseguire questo esempio localmente:

  1. Assicurati che Docker Desktop o un altro motore Docker locale siano in funzione.
  2. Salva il test in un _test.go file nel tuo modulo.
  3. Esegui go test -run TestWithContainer -v ./... dalla radice del modulo.

Usa questo approccio per la maggior parte della tua suite di test. L'avvio del container aggiunge qualche secondo, ma si ottiene una validazione reale di SQL Server che individua problemi che i mock non riescono a rilevare.

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: errore del certificato X.509

Con Go 1.23 e versioni successive, potresti riscontrare questo errore quando ti connetti a un container SQL Server:

x509: negative serial number

Go 1.23 applica rigorosamente la RFC 5280, e il certificato autofirmato generato da SQL Server in Docker utilizza un numero di serie negativo. Poiché i container di test non necessitano di TLS di livello produttivo, aggiungi TrustServerCertificate=true o encrypt=disable alla stringa di connessione di test:

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

Attenzione

Da usare TrustServerCertificate=true o encrypt=disable solo in ambienti di test. Per le connessioni di produzione, usa una validazione dei certificati adeguata. Vedi Crittografia e certificati.

Per ulteriori informazioni, vedere Risoluzione dei problemi.

Benchmark di prestazioni con test. B

Utilizza il framework benchmark integrato di Go per misurare le prestazioni operative del database:

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")
}

Esegui benchmark:

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

Flusso di lavoro completo di GitHub Actions

Questo esempio mostra una pipeline CI completa che configura un container SQL Server, crea uno schema di test ed esegue sia test unitari che di integrazione:

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 ./...

Verifica i percorsi di errore e la logica di ripetizione dei tentativi

Verifica che la tua applicazione gestisca correttamente gli errori temporanei e i nuovi tentativi:

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)
    }
}

Confronto delle strategie di prova

Strategy Velocità Real DB Dipendenze Ideale per
testcontainers-go Medio (secondi) Docker La maggior parte delle suite di test (consigliate).
Docker in CI Medio (secondi) Docker Pipeline CI/CD con GitHub Actions.
Annullamento della transazione Veloce (ms) SQL Server Test di integrazione su un database condiviso.
go-sqlmock Veloce (ms) No None Test unitari a ciclo interno solo per la logica delle app.
t.Skip con env var Istantaneo No None Degrado graduale quando non è disponibile alcun database.