Testmuster für go-mssqldb

Dieser Artikel behandelt Muster zum Schreiben von Integrationstests gegen SQL Server bei Verwendung des Treibersgo-mssqldb.

Wählen Sie den richtigen Testtyp

Ich bevorzuge es, gegen eine echte SQL Server-Instanz zu testen. Ein SQL Server-Container (über testcontainers-go, Docker Compose oder einen CI-Service) erkennt SQL-Syntaxfehler, Typfehler und Transaktionsverhalten, die Mocks nicht erkennen können. Dieser Ansatz ist derselbe Ansatz, den der go-mssqldb Treiber für seine eigene Testsuite verwendet. Unter Windows ist LocalDB eine leichte Alternative, die kein Docker benötigt.

Greifen Sie nur für schnelle Unit-Tests im inneren Entwicklungszyklus auf go-sqlmock zurück, bei denen die Startzeit des Containers die Ausführung dominieren würde. Verwenden Sie es zum Beispiel zum Testen der Anwendungsschicht-Retry-Logik oder zur Ergebnisabbildung.

Testtyp Verwenden Sie es für Vermeide es, wenn
Integrationstests mit testcontainers-go Reproduzierbare CI-Läufe und Suiten, die eine echte SQL Server-Instanz benötigen, ohne gemeinsam genutzte Infrastruktur zu verwalten. Schnelle Inner-Loop-Tests, bei denen die Startzeit des Containers die Ausführung dominiert.
Integrationstests gegen einen gemeinsamen oder lokalen SQL Server Gespeicherte Prozeduren, Schema-Objekte, Transaktionsverhalten, temporäre Tabellen und End-to-End-Treiberverhalten. Tests benötigen isolierte Infrastruktur oder müssen konsistent in CI ohne externe Abhängigkeiten laufen.
Einheitstests mit go-sqlmock Anwendungslogik wie Wiederholungsschleifen, Ergebniszuordnung und Fehlerverzweigungen, wenn keine Überprüfung der SQL-Syntax erforderlich ist. Du musst das Treiberverhalten, die SQL-Syntax mit dem SQL Server oder die Transaktionssemantik überprüfen.

Aufbau der Testdatenbank

Verwenden Sie Umweltvariablen, um den Test-Verbindungszeichenfolge für Integrationstests zu konfigurieren. Dieser Ansatz hält Zugangsdaten aus dem Quellcode heraus und macht die CI/CD-Integration einfach:

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

Setzen Sie die Umgebungsvariable vor dem Ausführen von Tests:

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

Verwenden Sie Transaktionen zur Testisolation

Wickle jeden Test in eine Transaktion und rücke am Ende zurück. Dieser Ansatz hält die Datenbank zwischen den Tests sauber:

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

Dieses Muster funktioniert am besten für Tests, die Repository-Code innerhalb einer einzigen Transaktionsgrenze durchführen. Es passt nicht gut zu Code, der interne Transaktionen öffnet und commitet, oder zu Tests, die das Verhalten über mehrere Verbindungen hinweg validieren müssen.

SQL Server in Docker für CI/CD

Verwenden Sie einen SQL Server Linux-Container für Integrationstests in CI-Pipelines:

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

Setzen Sie dann die Testverbindungszeichenfolge:

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

Überspringe Tests, wenn keine Datenbank verfügbar ist

Für Projekte, bei denen eine SQL Server-Instanz nicht immer verfügbar ist, überspringen Sie Integrationstests sorgfältig:

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

Testhilfe: Tabellen erstellen und ablegen

Erstelle eine Hilfsfunktion, die eine Testtabelle erstellt und sie nach dem Test beseitigt:

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

Unit-Tests mit go-sqlmock

Wenn die Startzeit des Containers für deinen inneren Entwicklungszyklus zu lang ist, erstellt go-sqlmock ein In-Memory-*sql.DB, das vordefinierte Ergebnisse zurückgibt. Nutze es für Anwendungslogik (Retry-Loops, Ergebnismapping, Fehlerverzweigung), bei denen du SQL-Syntax nicht gegen einen echten Server überprüfen musst:

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

Eine Abfrage simulieren

Richten Sie erwartete Abfragen ein und überprüfen Sie, ob die Anwendung die Ergebnisse korrekt handhabt:

Hinweis

sqlmock.ExpectQuery behandelt seine Eingabe als regulären Ausdruck und nicht als einfache SQL-Zeichenkette. Zeichen wie (, ), + und . müssen maskiert werden, um in SQL-Texten wörtlich mit ihnen übereinzustimmen. In Go-String-Literals erscheinen diese Escapes doppelt (zum Beispiel \\( bei einem Literal ( im 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)
    }
}

Einen Fehler simulieren

Geben Sie einen Fehler aus dem Mock zurück, um die Fehlerbehandlungspfade zu testen:

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

Entwirf deine Datenzugriffsfunktionen so, dass sie *sql.DB (oder eine Schnittstelle) als Parameter akzeptieren, anstatt eine globale Variable auf Paketebene zu verwenden. Dieses Muster erleichtert es, in Tests go-sqlmock Datenbanken zu ersetzen.

Integrationstests mit Testcontainers-go

testcontainers-goMan startet pro Testsuite einen SQL Server-Container auf und baut ihn automatisch ab. Dieser Ansatz wird für die meisten Testsuiten empfohlen, da er echtes SQL Server-Verhalten validiert, ohne gemeinsam genutzte Infrastruktur zu verwalten:

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

Um dieses Beispiel lokal auszuführen:

  1. Stelle sicher, dass Docker Desktop oder eine andere lokale Docker-Engine läuft.
  2. Speichere den Test in einer _test.go Datei in deinem Modul.
  3. Laufe go test -run TestWithContainer -v ./... vom Modul-Root aus.

Verwenden Sie diesen Ansatz für den Großteil Ihrer Testsuite. Der Start des Containers dauert ein paar Sekunden länger, aber dafür erhältst du eine Validierung mit SQL Server unter realen Bedingungen, die Probleme aufdeckt, die Mocks nicht erfassen.

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: x509-Zertifikatsfehler

Mit Go 1.23 und später könnte dieser Fehler auftreten, wenn Sie sich mit einem SQL Server-Container verbinden:

x509: negative serial number

Go 1.23 setzt RFC 5280 streng durch, und das von SQL Server in Docker generierte selbstsignierte Zertifikat verwendet eine negative Seriennummer. Da Testcontainer kein TLS auf Produktionsniveau benötigen, fügen Sie TrustServerCertificate=true oder encrypt=disable zur Testverbindungszeichenfolge hinzu:

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

Achtung

Verwenden Sie TrustServerCertificate=true oder encrypt=disable nur in Testumgebungen. Für Produktionsverbindungen verwenden Sie eine ordnungsgemäße Zertifikatsvalidierung. Siehe Verschlüsselung und Zertifikate.

Weitere Informationen finden Sie unter "Problembehandlung".

Leistungs-Benchmarks durch Tests.B

Verwenden Sie Gos integriertes Benchmark-Framework, um die Leistung von Datenbankoperationen zu messen:

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

Führe Benchmarks durch:

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

Vollständiger GitHub Actions-Workflow

Dieses Beispiel zeigt eine vollständige CI-Pipeline, die einen SQL Server-Container einrichtet, ein Testschema erstellt und sowohl Unit- als auch Integrationstests durchführt:

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

Testfehlerpfade und Wiederholungslogik

Testen Sie, ob Ihre Anwendung vorübergehende Fehler und Wiederholungen korrekt handhabt:

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

Vergleich von Teststrategien

Strategy Speed Real DB Abhängigkeiten Am besten geeignet für:
testcontainers-go Mittel (Sekunden) Ja Docker Die meisten Testsuiten (empfohlen).
Docker in CI Mittel (Sekunden) Ja Docker CI/CD-Pipelines mit GitHub Actions.
Transaktionsrücksetzung Schnell (ms) Ja SQL Server Integrationstests in einer gemeinsamen Datenbank.
go-sqlmock Schnell (ms) No Nichts Inner-Loop-Einheitstests nur für App-Logik.
t.Skip mit Umgebungsvariable Sofort No Nichts Anmutige Degradation, wenn kein DB verfügbar ist.