Pola pengujian untuk go-mssqldb

Artikel ini membahas pola untuk menulis pengujian integrasi terhadap SQL Server saat menggunakan go-mssqldb driver.

Pilih jenis tes yang tepat

Lebih suka pengujian terhadap instans SQL Server nyata. Kontainer SQL Server (melalui testcontainers-go, Docker Compose, atau layanan CI) menangkap kesalahan sintaks SQL, ketidakcocokan jenis, dan perilaku transaksi yang tidak dapat dideteksi oleh tiruan. Pendekatan ini sama dengan pendekatan yang digunakan go-mssqldb driver untuk suite pengujiannya sendiri. Di Windows, LocalDB adalah alternatif ringan yang tidak memerlukan Docker.

Kembali ke go-sqlmock hanya untuk pengujian unit loop dalam yang cepat di mana waktu startup kontainer akan mendominasi eksekusi. Misalnya, gunakan untuk menguji logika coba ulang lapisan aplikasi atau pemetaan hasil.

Jenis pengujian Gunakan untuk itu Hindari hal ini saat
Pengujian integrasi dengan testcontainers-go Eksekusi CI yang dapat direproduksi dan rangkaian pengujian yang memerlukan instans SQL Server nyata tanpa perlu mengelola infrastruktur bersama. Pengujian loop dalam yang cepat di mana waktu startup kontainer akan mendominasi eksekusi.
Pengujian integrasi terhadap SQL Server bersama atau lokal Prosedur tersimpan, objek skema, perilaku transaksi, tabel sementara, dan perilaku driver secara menyeluruh. Pengujian memerlukan infrastruktur terisolasi atau harus berjalan secara konsisten di CI tanpa dependensi eksternal.
Pengujian unit dengan go-sqlmock Logika lapisan aplikasi seperti perulangan coba ulang, pemetaan hasil, dan percabangan kesalahan saat Anda tidak perlu memvalidasi sintaks SQL. Anda perlu memverifikasi perilaku driver, sintaks SQL terhadap SQL Server, atau semantik transaksi.

Uji penyiapan database

Gunakan variabel lingkungan untuk mengonfigurasi string koneksi pengujian untuk pengujian integrasi. Pendekatan ini menjauhkan kredensial dari kode sumber dan membuat integrasi CI/CD menjadi mudah:

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

Atur variabel lingkungan sebelum menjalankan pengujian:

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

Gunakan transaksi untuk isolasi pengujian

Bungkus setiap pengujian dalam transaksi dan gulung kembali di akhir. Pendekatan ini menjaga database tetap bersih di antara pengujian:

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

Pola ini berfungsi paling baik untuk pengujian yang menjalankan kode repositori di dalam batas transaksi tunggal. Ini tidak cocok untuk kode yang membuka dan melakukan transaksinya sendiri secara internal, atau untuk pengujian yang perlu memvalidasi perilaku di beberapa koneksi.

SQL Server di Docker untuk CI/CD

Gunakan kontainer SQL Server Linux untuk pengujian integrasi dalam alur 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

Kemudian atur string koneksi pengujian:

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

Lewati pengujian saat tidak ada database yang tersedia

Untuk proyek di mana instans SQL Server mungkin tidak selalu tersedia, lewati pengujian integrasi dengan anggun:

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

Utilitas pengujian: membuat dan menghapus tabel

Buat fungsi pembantu yang menyiapkan tabel pengujian dan membersihkannya setelah pengujian:

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

Pengujian unit dengan go-sqlmock

Jika waktu startup kontainer terlalu lambat untuk siklus pengembangan internal Anda, go-sqlmock membuat *sql.DB di memori yang mengembalikan hasil yang telah ditentukan. Gunakan untuk logika lapisan aplikasi (perulangan coba ulang, pemetaan hasil, percabangan kesalahan) di mana Anda tidak perlu memvalidasi sintaks SQL terhadap server nyata:

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

Menyimulasikan kueri

Siapkan kueri yang diharapkan dan verifikasi aplikasi menangani hasilnya dengan benar:

Note

sqlmock.ExpectQuery memperlakukan inputnya sebagai ekspresi reguler, bukan string SQL biasa. Karakter seperti (, ), , +dan . harus diloloskan agar mencocokkannya secara harfiah dalam teks SQL. Dalam literal string Go, karakter escape ini ditulis ganda (misalnya, \\( untuk literal ( pada 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)
    }
}

Mengejek kesalahan

Mengembalikan kesalahan dari tiruan untuk menguji jalur penanganan kesalahan:

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

Rancang fungsi akses data Anda agar menerima *sql.DB (atau antarmuka) sebagai parameter, alih-alih menggunakan variabel global tingkat paket. Pola ini memudahkan untuk mengganti go-sqlmock database dalam pengujian.

Pengujian integrasi dengan testcontainers-go

testcontainers-go memulai kontainer SQL Server untuk setiap rangkaian pengujian dan menghapusnya secara otomatis. Pendekatan ini direkomendasikan untuk sebagian besar rangkaian pengujian karena memvalidasi perilaku SQL Server nyata tanpa mengelola infrastruktur bersama:

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

Untuk menjalankan contoh ini secara lokal:

  1. Pastikan Docker Desktop atau mesin Docker lokal lainnya sedang berjalan.
  2. Simpan pengujian dalam _test.go file di modul Anda.
  3. Jalankan go test -run TestWithContainer -v ./... dari root modul.

Gunakan pendekatan ini untuk sebagian besar rangkaian pengujian Anda. Startup container memerlukan beberapa detik tambahan, tetapi Anda mendapatkan validasi SQL Server yang sesungguhnya yang dapat menangkap masalah yang luput dari mock.

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: kesalahan sertifikat X.509

Dengan Go 1.23 dan yang lebih baru, Anda mungkin melihat kesalahan ini saat menyambungkan ke kontainer SQL Server:

x509: negative serial number

Go 1.23 memberlakukan RFC 5280 secara ketat, dan sertifikat yang ditandatangani sendiri yang dihasilkan oleh SQL Server di Docker menggunakan nomor seri negatif. Karena kontainer pengujian tidak memerlukan TLS tingkat produksi, tambahkan TrustServerCertificate=true atau encrypt=disable ke string koneksi pengujian:

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

Caution

Gunakan TrustServerCertificate=true atau encrypt=disable hanya di lingkungan pengujian. Untuk koneksi produksi, gunakan validasi sertifikat yang tepat. Lihat Enkripsi dan sertifikat.

Untuk informasi selengkapnya, lihat Pemecahan masalah.

Tolok ukur performa dengan pengujian. B

Gunakan kerangka kerja tolok ukur bawaan Go untuk mengukur performa operasi database:

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

Jalankan tolok ukur:

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

Alur kerja GitHub Actions lengkap

Contoh ini menunjukkan alur CI lengkap yang menyiapkan kontainer SQL Server, membuat skema pengujian, dan menjalankan pengujian unit dan integrasi:

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

Uji skenario error dan logika coba ulang

Uji apakah aplikasi Anda menangani kesalahan sementara dan percobaan ulang dengan benar:

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

Perbandingan strategi pengujian

Strategy Kecepatan DB Aktual Ketergantungan Paling cocok untuk
testcontainers-go Sedang (detik) Yes Docker Sebagian besar rangkaian pengujian (direkomendasikan).
Petugas pelabuhan di CI Menengah (detik) Yes Docker Alur CI/CD dengan GitHub Actions.
Pembatalan transaksi Cepat (ms) Yes SQL Server Pengujian integrasi pada database bersama.
go-sqlmock Cepat (ms) No None Pengujian unit loop dalam hanya untuk logika aplikasi.
t.Skip Dengan ENV VAR Instan No None Penurunan fungsi secara bertahap saat database tidak tersedia.