Testpatronen voor go-mssqldb

Dit artikel behandelt patronen voor het schrijven van integratietests tegen SQL Server bij gebruik van de go-mssqldb driver.

Kies het juiste testtype

Ik geef de voorkeur aan testen tegen een echte SQL Server-instantie. Een SQL Server-container (via testcontainers-go, Docker Compose of een CI-service) detecteert SQL-syntaxisfouten, typeverschillen en transactiegedrag die mocks niet kunnen detecteren. Deze aanpak is dezelfde aanpak die de go-mssqldb driver gebruikt voor zijn eigen testsuite. Op Windows is LocalDB een lichtgewicht alternatief dat geen Docker vereist.

Gebruik go-sqlmock alleen als terugvaloptie voor snelle inner-loop-unittests waarbij de opstarttijd van containers de uitvoeringstijd zou domineren. Gebruik het bijvoorbeeld voor het testen van de applicatielaag retry-logica of resultaatmapping.

Type test Gebruik het voor Vermijd het wanneer
Integratietests met testcontainers-go Reproducerbare CI-runs en suites die een echte SQL Server-instantie nodig hebben zonder gedeelde infrastructuur te beheren. Snelle binnenloop-tests waarbij de opstarttijd van de container de uitvoering zou domineren.
Integratietests tegen een gedeelde of lokale SQL Server Opgeslagen procedures, schema-objecten, transactiegedrag, tijdelijke tabellen en end-to-end drivergedrag. Tests hebben geïsoleerde infrastructuur nodig of moeten consistent in CI draaien zonder externe afhankelijkheden.
Unit tests met go-sqlmock Applicatielaaglogica zoals retry loops, result mapping en error branching wanneer je SQL-syntaxis niet hoeft te valideren. Je moet het drivergedrag, SQL-syntaxis verifiëren aan de hand van SQL Server of transactiesemantiek.

Testdatabase-opstelling

Gebruik omgevingsvariabelen om de test-verbindingsreeks te configureren voor integratietests. Deze aanpak houdt inloggegevens uit de broncode en maakt CI/CD-integratie eenvoudig:

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

Stel de omgevingsvariabele in voordat je tests uitvoert:

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

Gebruik transacties voor testisolatie

Voer elke test uit binnen een transactie en draai deze aan het einde terug. Deze aanpak houdt de database schoon tussen de 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)
    }
}

Dit patroon werkt het beste voor tests die repositorycode binnen één transactiegrens uitvoeren. Het is niet geschikt voor code die intern zijn eigen transacties opent en vastlegt, of voor tests die gedrag via meerdere verbindingen moeten valideren.

SQL Server in Docker voor CI/CD

Gebruik een SQL Server Linux-container voor integratietests in CI-pijplijnen:

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

Stel vervolgens de testverbindingsreeks in:

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

Sla tests over wanneer er geen database beschikbaar is

Voor projecten waarbij een SQL Server-instantie niet altijd beschikbaar is, sla integratietests soepel over:

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

Testhulp: tabellen aanmaken en verwijderen

Maak een helperfunctie aan die een testtabel opzet en deze na de test opschoont:

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

Unittests met go-sqlmock

Als de opstarttijd van de container te traag is voor je interne ontwikkellus, go-sqlmock creëert je een in-memory *sql.DB die vooraf gedefinieerde resultaten teruggeeft. Gebruik het voor applicatie-laag logica (retry loops, result mapping, error branching) waarbij je SQL-syntaxis niet hoeft te valideren tegen een echte server:

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

Een query simuleren

Stel verwachte queries in en controleer of de applicatie de resultaten correct afhandelt:

Opmerking

sqlmock.ExpectQuery behandelt zijn invoer als een reguliere expressie, niet als een gewone SQL-string. Tekens zoals (, ), + en . moeten worden ge-escaped om er letterlijk naar te zoeken in SQL-tekst. In stringliteralen in Go worden deze escape-sequenties verdubbeld weergegeven (bijvoorbeeld \\( voor een letterlijke ( in de reguliere expressie).

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

Een fout simuleren

Geef een foutmelding terug uit de mock om foutafhandelingspaden te 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

Ontwerp je data-toegangsfuncties zo dat ze (of een interface) als parameter accepteren *sql.DB , in plaats van een pakketniveau global te gebruiken. Dit patroon maakt het eenvoudig om databases in tests te vervangen go-sqlmock .

Integratietests met testcontainers-go

testcontainers-go start per testsuite een SQL Server-container en verwijdert deze automatisch. Deze aanpak wordt aanbevolen voor de meeste testsuites omdat het echt SQL Server-gedrag valideert zonder gedeelde infrastructuur te beheren:

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

Om dit voorbeeld lokaal uit te voeren:

  1. Zorg dat Docker Desktop of een andere lokale Docker-engine draait.
  2. Sla de test op in een _test.go bestand in je module.
  3. Voer go test -run TestWithContainer -v ./... uit vanuit de hoofdmap van de module.

Gebruik deze aanpak voor het grootste deel van je testsuite. Het opstarten van containers kost een paar seconden extra, maar je krijgt daadwerkelijke SQL Server-validatie die problemen aan het licht brengt die mocks niet opmerken.

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 certificaatfout

Met Go 1.23 en later kun je deze fout zien bij het verbinden met een SQL Server-container:

x509: negative serial number

Go 1.23 handhaaft RFC 5280 strikt, en het zelfondertekende certificaat dat door SQL Server in Docker wordt gegenereerd, gebruikt een negatief serienummer. Aangezien testcontainers geen TLS op productieniveau nodig hebben, voeg je TrustServerCertificate=true of encrypt=disable toe aan de testverbindingsreeks:

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

Let op

Gebruik TrustServerCertificate=true of encrypt=disable alleen in testomgevingen. Voor productieverbindingen gebruik je de juiste certificaatvalidatie. Zie Versleuteling en certificaten.

Zie Probleemoplossing voor meer informatie.

Prestatiebenchmarks met tests.B

Gebruik het ingebouwde benchmarkframework van Go om de prestaties van databaseoperaties te meten:

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

Voer benchmarks uit:

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

Volledige GitHub Actions-workflow

Dit voorbeeld toont een complete CI-pijplijn die een SQL Server-container opzet, een testschema maakt en zowel unit- als integratietests uitvoert:

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

Test foutpaden en logica voor herhaalpogingen

Controleer of je applicatie tijdelijke fouten en herhalingen correct afhandelt:

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

Teststrategievergelijking

Strategy Snelheid Echte database Dependencies Ideaal voor
testcontainers-go Gemiddeld (seconden) Ja Docker De meeste testsuites (aanbevolen).
Docker in CI Gemiddeld (seconden) Ja Docker CI/CD-pijplijnen met GitHub Actions.
Transactie terugdraaien Snel (ms) Ja SQL Server Integratietests op een gedeelde database.
go-sqlmock Snel (ms) No Geen Inner-loop unit tests alleen voor app-logica.
t.Skip met omgevingsvariabele Instant No Geen Elegante degradatie wanneer er geen DB beschikbaar is.