go-mssqldb 的測試模式

本文介紹了在使用go-mssqldb驅動程式時,針對 SQL Server 撰寫整合測試的模式。

選擇合適的測試類型

建議用真實的 SQL Server 實例來測試。 SQL Server 容器(透過 testcontainers-go、Docker Compose 或 CI 服務)會偵測 SQL 語法錯誤、型別不符以及 mock 無法偵測的交易行為。 這種方法與 go-mssqldb 驅動程式用於其自身測試套件的方法相同。 在 Windows 上,LocalDB 是一個輕量級的替代方案,不需要 Docker。

只有在快速的 inner-loop 單元測試中,且容器啟動時間會占執行時間大宗時,才回退至 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)
    }
}

此模式最適合在單一交易邊界內執行儲存庫程式碼的測試。 它不適合那些在內部開啟並提交交易的程式碼,或是需要驗證多重連線行為的測試。

用於 CI/CD 的 Docker 中 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

模擬查詢

設定預期查詢並驗證應用程式是否正確處理結果:

Note

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,而 Docker 中 SQL Server 產生的自簽憑證使用負序號。 由於測試容器不需要生產環境等級的 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 速度 真實資料庫 依賴 最適合用於
testcontainers-go 中等(秒) 是的 Docker 大多數測試套件(推薦)。
CI 中的 Docker 中等(秒) 是的 Docker 使用 GitHub Actions 的 CI/CD 流程
交易復原 快速(毫秒) 是的 SQL Server 在共享資料庫上的整合測試。
go-sqlmock 快速(毫秒) No None 僅針對應用程式邏輯的內部迴圈單元測試。
t.Skip 搭配環境變數 立即 No None 當資料庫無法使用時,平順降級。