go-mssqldb 的测试模式

本文介绍了在使用go-mssqldb驱动时针对SQL Server编写集成测试的模式。

选择合适的测试类型

更倾向于用真实的 SQL Server 实例进行测试。 SQL Server 容器(通过 testcontainers-go、Docker Compose 或 CI 服务)会捕捉 SQL 语法错误、类型不匹配和事务行为,这些都是模拟器无法检测到的。 这种方法与驱动程序在自身测试套件中使用的方法相同 go-mssqldb 。 在 Windows 上,LocalDB 是一个轻量级的替代方案,不需要 Docker。

仅在快速的内环单元测试中,容器启动时间会主导执行,才会回退到 go-sqlmock 。 例如,可以用它来测试应用层重试逻辑或结果映射。

测试类型 将其用于 在……时避免这样做
使用 testcontainers-go 的集成测试 可重现的CI运行和套件需要真实的SQL Server实例,而无需管理共享基础设施。 适用于快速的内部循环测试,因为容器启动时间会占据执行时间的主要部分。
针对共享或本地 SQL Server 的集成测试 存储过程、模式对象、事务行为、临时表以及端到端驱动行为。 测试需要隔离的基础设施,或者必须在CI中稳定运行,没有外部依赖。
使用 go-sqlmock 的单元测试 无需验证 SQL 语法时的应用层逻辑,例如重试循环、结果映射和错误处理分支等。 你需要验证驱动程序行为、SQL语法与SQL Server的匹配,或者事务语义。

测试数据库设置

使用环境变量配置用于集成测试的测试连接字符串。 这种方法避免了凭证出现在源代码中,使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())
}

在运行测试前设置环境变量:

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

使用事务实现测试隔离

将每个测试放在事务中执行,并在结束时回滚。 这种方法在测试间保持数据库的清洁:

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

该模式最适合在单个事务边界内进行存储库代码的测试。 它不适合那些内部开启并提交事务的代码,也不适合需要验证多连接行为的测试。

Docker 中用于 CI/CD 的 SQL Server

在CI管道中使用SQL Server Linux容器进行集成测试:

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

然后设置测试连接字符串:

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

没有数据库时跳过测试

对于那些 SQL Server 实例可能并不总是可用的项目,可以优雅地跳过集成测试:

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

测试助手:创建和删除表

创建一个辅助函数,设置测试表并在测试后清理:

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

使用 go-sqlmock 进行单元测试

如果容器启动时间对于内部开发迭代循环来说太慢,go-sqlmock 会创建一个内存中的 *sql.DB,它会返回预定义的结果。 用它来做应用层逻辑(重试循环、结果映射、错误分支),这样你就不需要在真实服务器上验证SQL语法:

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

模拟查询

设置预期查询并验证应用程序正确处理结果:

注释

sqlmock.ExpectQuery 将输入视为正则表达式,而非普通的SQL字符串。 像 ()+. 这样的字符必须进行转义,才能在 SQL 文本中按字面值匹配它们。 在 Go 字符串字面量中,这些转义序列会显示为双写(例如,\\( 表示正则表达式中的字面值 ()。

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

模拟错误

从模拟中返回一个错误以测试错误处理路径:

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

将数据访问函数设计为接收 *sql.DB(或某个接口)作为参数,而不是使用包级全局变量。 这种模式使得在测试中替代 go-sqlmock 数据库变得简单。

使用 testcontainers-go 进行集成测试

testcontainers-go每个测试套件启动一个 SQL Server 容器并自动拆解。 这种方法被大多数测试套件推荐,因为它在不管理共享基础设施的情况下验证真实的 SQL Server 行为:

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

本地运行这个例子:

  1. 确保 Docker Desktop 或其他本地 Docker 引擎正在运行。
  2. 把测试保存在模块里的文件 _test.go 里。
  3. 从模块根开始运行 go test -run TestWithContainer -v ./...

对大部分测试套件都采用这种方法。 容器启动会多花几秒钟,但你会得到真正的SQL Server验证,能发现模拟遗漏的问题。

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证书错误

在Go 1.23及以后版本,连接SQL Server容器时你可能会看到以下错误:

x509: negative serial number

Go 1.23严格执行RFC 5280,而SQL Server在Docker中生成的自签名证书使用负序列号。 由于测试容器不需要生产级别的 TLS,请将 TrustServerCertificate=trueencrypt=disable 添加到测试连接字符串中:

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

注意

仅在测试环境中使用 TrustServerCertificate=trueencrypt=disable。 对于生产连接,请使用正确的证书验证。 参见 加密与证书

有关详细信息,请参阅 故障排除

带测试的性能基准B

使用 Go 内置的基准测试框架来衡量数据库运行性能:

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

运行基准测试:

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

完整的 GitHub Actions 工作流程

这个示例展示了一个完整的CI流水线,它搭建了SQL Server容器,创建测试模式,并运行单元测试和集成测试:

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

测试错误路径与重试逻辑

测试你的应用程序是否正确处理瞬态错误和重试:

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

测试策略比较

Strategy Speed 真实数据库 Dependencies 最适用于
testcontainers-go 中等(秒) 是的 Docker 大多数测试套件(推荐)。
Docker 在 CI 中的应用 中位数(秒) 是的 Docker 使用 GitHub Actions 的 CI/CD 流水线
事务回滚 快(毫秒) 是的 SQL Server 在共享数据库上进行集成测试。
go-sqlmock 快速(ms) None 仅用于应用逻辑的内环单元测试。
t.Skip 使用环境变量 即时 None 在没有可用数据库时实现优雅降级。