Modèles de test pour go-mssqldb

Cet article traite des schémas pour écrire des tests d’intégration avec SQL Server lors de l’utilisation du go-mssqldb pilote.

Choisissez le bon type de test

Je préfère tester contre une vraie instance SQL Server. Un conteneur SQL Server (via testcontainers-goDocker Compose ou un service CI) détecte les erreurs de syntaxe SQL, les incompatibilités de type et des comportements de transactions que les mocks ne peuvent pas détecter. Cette approche est la même que celle que le go-mssqldb pilote utilise pour sa propre suite de tests. Sous Windows, LocalDB est une alternative légère qui ne nécessite pas Docker.

Recourez à go-sqlmock uniquement pour des tests unitaires rapides de boucle interne, pour lesquels le temps de démarrage du conteneur dominerait le temps d’exécution. Par exemple, utilisez-le pour tester la logique de réévaluation au niveau applicatif ou la correspondance des résultats.

Type de test Utilisez-le pour Évitez-le quand
Tests d’intégration avec testcontainers-go Des systèmes de CI reproductibles et des suites qui nécessitent une véritable instance SQL Server sans gérer une infrastructure partagée. Des tests rapides en boucle interne où le temps de démarrage du conteneur dominerait l’exécution.
Tests d’intégration sur un SQL Server partagé ou local Procédures stockées, objets de schéma, comportement des transactions, tables temporaires et comportement des pilotes de bout en bout. Les tests nécessitent une infrastructure isolée ou doivent fonctionner de manière cohérente en CI sans dépendances externes.
Tests unitaires avec go-sqlmock La logique de la couche applicative, comme les boucles de nouvelle tentative, la mise en correspondance des résultats et le traitement conditionnel des erreurs lorsque vous n’avez pas besoin de valider la syntaxe SQL. Vous devez vérifier le comportement des pilotes, la syntaxe SQL par rapport à SQL Server, ou la sémantique des transactions.

Configuration de la base de données de test

Utilisez des variables d’environnement pour configurer la chaîne de connexion de test pour les tests d’intégration. Cette approche empêche les identifiants d’entrer dans le code source et facilite l’intégration 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())
}

Définissez la variable d’environnement avant d’effectuer les tests :

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

Utiliser les transactions pour l’isolation des tests

Exécutez chaque test dans une transaction et annulez-la à la fin. Cette approche maintient la base de données propre entre les tests :

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

Ce schéma fonctionne mieux pour les tests qui exercent du code de dépôt à l’intérieur d’une seule frontière de transaction. Ce n’est pas un bon choix pour du code qui ouvre et envoie ses propres transactions en interne, ni pour des tests qui doivent valider un comportement sur plusieurs connexions.

SQL Server dans Docker pour CI/CD

Utilisez un conteneur Linux SQL Server pour les tests d’intégration dans les pipelines 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

Ensuite, définissez la chaîne de connexion de test :

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

Ignorer les tests lorsqu’aucune base de données n’est disponible

Pour les projets où une instance SQL Server n’est pas toujours disponible, évitez les tests d’intégration avec aisance :

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

Assistant de test : créer et supprimer des tables

Créez une fonction d’assistance qui configure une table de test et la nettoie après le 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()
}

Tests unitaires avec go-sqlmock

Si le temps de démarrage du go-sqlmock conteneur est trop lent pour votre boucle de développement interne, cela *sql.DB crée une mémoire interne qui restitue des résultats prédéfinis. Utilisez-le pour la logique au niveau applicatif (boucles de réessayage, mappage de résultats, embranchement d’erreurs) où vous n’avez pas besoin de valider la syntaxe SQL contre un vrai serveur :

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

Simuler une requête

Configurez les requêtes attendues et vérifiez que l’application gère correctement les résultats :

Note

sqlmock.ExpectQuery traite son entrée comme une expression régulière, et non comme une chaîne SQL simple. Des caractères comme (, ), +, et . doivent être échappés pour les correspondre littéralement dans le texte SQL. Dans les littéraux de chaînes Go, ces échappements apparaissent doublés (par exemple, \\( pour un littéral ( dans le 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)
    }
}

Simuler une erreur

Retournez une erreur de la simulation pour tester les chemins de gestion des erreurs :

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

Concevez vos fonctions d’accès aux données pour qu’elles acceptent *sql.DB (ou utilisent une interface) comme paramètre plutôt qu’en utilisant un global au niveau du package. Ce modèle facilite le remplacement de go-sqlmock bases de données dans les tests.

Tests d’intégration avec testcontainers-go

testcontainers-go démarre un conteneur SQL Server par suite de tests et le supprime automatiquement. Cette approche est recommandée pour la plupart des suites de tests car elle valide le comportement réel de SQL Server sans gérer une infrastructure partagée :

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

Pour exécuter cet exemple localement :

  1. Assurez-vous que Docker Desktop ou un autre moteur Docker local fonctionne.
  2. Sauvegardez le test dans un _test.go fichier de votre module.
  3. Exécute go test -run TestWithContainer -v ./... depuis la racine du module.

Utilisez cette approche pour la majeure partie de votre suite de tests. Le démarrage du conteneur ajoute quelques secondes, mais vous obtenez une vraie validation SQL Server qui détecte les problèmes et les simulations ratées.

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 : erreur de certificat x509

Avec Go 1.23 et versions ultérieures, vous pourriez voir cette erreur lors de la connexion à un conteneur SQL Server :

x509: negative serial number

Go 1.23 applique strictement la RFC 5280, et le certificat auto-signé généré par SQL Server dans Docker utilise un numéro de série négatif. Puisque les conteneurs de test n’ont pas besoin d’un TLS de niveau production, ajoutez TrustServerCertificate=true ou encrypt=disable à la chaîne de connexion de test :

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

Attention

À utiliser TrustServerCertificate=true ou encrypt=disable uniquement dans des environnements de test. Pour les connexions de production, utilisez une validation de certificat appropriée. Voir Chiffrement et certificats.

Pour plus d’informations, consultez la page Dépannage.

Des benchmarks de performance avec des tests. B

Utilisez le framework de benchmark intégré de Go pour mesurer la performance des opérations de base de données :

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

Exécutez des benchmarks :

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

Flux de travail complet sur GitHub Actions

Cet exemple montre un pipeline CI complet qui met en place un conteneur SQL Server, crée un schéma de test et exécute à la fois des tests unitaires et d’intégration :

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

Chemins d’erreur de test et logique de réévaluation

Vérifiez que votre application gère correctement les erreurs transitoires et les tentatives :

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

Comparaison des stratégies de test

Strategy Vitesse Real DB Dependencies Idéal pour
testcontainers-go Moyen (secondes) Oui Docker La plupart des suites de tests (recommandées).
Docker dans CI Moyen (secondes) Oui Docker Pipelines de CI/CD avec GitHub Actions.
Annulation des transactions Rapide (ms) Oui SQL Server Tests d’intégration sur une base de données partagée.
go-sqlmock Rapide (ms) Non None Tests unitaires en boucle interne uniquement pour la logique applicative.
t.Skip avec variable d’environnement Immédiat Non None Dégradation gracieuse lorsqu’aucune base de données n’est disponible.