Padrões de teste para go-mssqldb

Este artigo aborda padrões para escrever testes de integração contra o SQL Server ao utilizar o go-mssqldb driver.

Escolha o tipo de teste certo

Prefiro testar contra uma instância real do SQL Server. Um contentor SQL Server (via testcontainers-go, Docker Compose ou um serviço CI) detém erros de sintaxe SQL, incompatibilidades de tipos e comportamentos de transações que os mocks não conseguem detetar. Esta abordagem é a mesma que o go-mssqldb driver utiliza para o seu próprio conjunto de testes. No Windows, o LocalDB é uma alternativa leve que não requer Docker.

Recorra a go-sqlmock apenas para testes unitários rápidos do ciclo interno de desenvolvimento, em que o tempo de arranque do contentor dominaria a execução. Por exemplo, use-o para testar lógica de retentativas na camada de aplicação ou mapeamento de resultados.

Tipo de ensaio Usa-o para Evite-o quando
Testes de integração com testcontainers-go Execuções de CI reproduzíveis e suites que precisam de uma instância real de SQL Server sem gerir a infraestrutura partilhada. Testes rápidos de ciclo interno onde o tempo de arranque do contentor dominaria a execução.
Testes de integração contra um SQL Server partilhado ou local Procedimentos armazenados, objetos de esquema, comportamento de transações, tabelas temporárias e comportamento de drivers de ponta a ponta. Os testes necessitam de infraestrutura isolada ou devem correr consistentemente em CI sem dependências externas.
Testes unitários com go-sqlmock Lógica da camada da aplicação, como ciclos de repetição, mapeamento de resultados e tratamento condicional de erros, quando não é necessário validar a sintaxe SQL. É preciso verificar o comportamento do driver, a sintaxe SQL em relação ao SQL Server ou a semântica das transações.

Configuração da base de dados de teste

Utilize variáveis de ambiente para configurar a cadeia de ligação de teste para testes de integração. Esta abordagem mantém credenciais fora do código-fonte e torna a integração CI/CD simples:

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

Defina a variável de ambiente antes de executar os testes:

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

Usar transações para isolamento de testes

Execute cada teste numa transação e anule-a no final. Esta abordagem mantém a base de dados limpa entre os testes:

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 padrão funciona melhor para testes que exercem código de repositório dentro de um único limite de transação. Não é adequado para código que abre e confirma as suas próprias transações internamente, nem para testes que precisam de validar o comportamento em múltiplas ligações.

SQL Server no Docker para CI/CD

Use um contentor Linux do SQL Server para testes de integração em 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

Depois, defina a cadeia de ligação de teste:

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

Saltar testes quando não existe uma base de dados disponível

Para projetos onde uma instância do SQL Server pode nem sempre estar disponível, evite os testes de integração com facilidade:

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

Auxiliar de teste: criar e eliminar tabelas

Crie uma função auxiliar que configure uma mesa de teste e a limpe após o teste:

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

Testes unitários com go-sqlmock

Se o tempo de arranque do contentor for demasiado lento para o seu ciclo interno de desenvolvimento, go-sqlmock cria-se uma memória *sql.DB interna que devolve resultados pré-definidos. Utilize-o para lógica da camada de aplicação (ciclos de repetição, mapeamento de resultados, tratamento de erros com ramificações condicionais) nos casos em que não seja necessário validar a sintaxe SQL num servidor real:

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

Simular uma consulta

Configure as consultas esperadas e verifique se a aplicação gere corretamente os resultados:

Note

sqlmock.ExpectQuery trata a sua entrada como uma expressão regular, não como uma string SQL simples. Caracteres como (, ), +, e . têm de ser escapados para corresponder literalmente a eles em texto SQL. Nos literais da cadeia Go, estes escapes aparecem duplicados (por exemplo, \\( para um literal ( no 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)
    }
}

Simular um erro

Faça com que o mock devolva um erro para testar os fluxos de tratamento de erros:

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

Projete as suas funções de acesso a dados para aceitarem *sql.DB (ou uma interface) como parâmetro, em vez de usar um global ao nível de pacote. Este padrão facilita a substituição de go-sqlmock bases de dados nos testes.

Testes de integração com testcontainers-go

testcontainers-golança um contentor SQL Server por cada conjunto de testes e desmonta-o automaticamente. Esta abordagem é recomendada para a maioria das suites de testes porque valida o comportamento real do SQL Server sem gerir a infraestrutura partilhada:

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

Para executar este exemplo localmente:

  1. Certifica-te de que o Docker Desktop ou outro motor Docker local está a funcionar.
  2. Guarda o teste num _test.go ficheiro no teu módulo.
  3. Executa go test -run TestWithContainer -v ./... a partir da raiz do módulo.

Use esta abordagem para a maior parte do seu conjunto de testes. O arranque do contentor adiciona alguns segundos, mas permite uma validação real do SQL Server que deteta problemas que os mocks não detetam.

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: erro de certificado x509

Com o Go 1.23 e versões posteriores, pode ver este erro ao ligar a um contentor do SQL Server:

x509: negative serial number

O Go 1.23 aplica rigorosamente o RFC 5280, e o certificado autoassinado gerado pelo SQL Server no Docker usa um número de série negativo. Como os contentores de teste não precisam de TLS com nível de produção, adicione TrustServerCertificate=true ou encrypt=disable à cadeia de ligação de teste:

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

Caution

Uso TrustServerCertificate=true ou encrypt=disable apenas em ambientes de teste. Para ligações de produção, utilize a validação adequada dos certificados. Veja Encriptação e certificados.

Para mais informações, consulte Resolução de Problemas.

Referências de desempenho com testes.B

Use a estrutura de benchmarks incorporada do Go para medir o desempenho operacional da base de dados:

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

Executar testes de desempenho:

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

Fluxo de trabalho completo do GitHub Actions

Este exemplo mostra um pipeline completo de CI que configura um contentor SQL Server, cria um esquema de teste e executa tanto testes unitários como de integração:

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

Teste os cenários de erro e a lógica de nova tentativa

Verifique se a sua aplicação lida corretamente com erros transitórios e faz novas tentativas:

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

Comparação de estratégias de teste

Strategy Velocidade Real DB Dependencies Melhor para
testcontainers-go Médio (segundos) Sim Docker A maioria das suítes de testes (recomendada).
Docker em CI Médio (segundos) Sim Docker Pipelines de CI/CD com o GitHub Actions.
Reversão de transações Rápido (ms) Sim SQL Server Testes de integração numa base de dados partilhada.
go-sqlmock Rápido (ms) No None Testes unitários de ciclo interno apenas para a lógica da aplicação.
t.Skip Com Env Var Instantâneo No None Degradação gradual quando não há base de dados disponível.