Mert Özen Her Satırda Daha İleri
Backend Go ile Backend — #12

Go'da Test: testing Paketi, Table-Driven Test, httptest, Benchmark ve synctest

Mert Özen 15 Kas 2026 17 dk 3 görüntülenme
Go'da Test: testing Paketi, Table-Driven Test, httptest, Benchmark ve synctest

Go'da test için framework kurulmaz: _test.go dosyaları, t.Run ile table-driven test, interface'i karşılayan elle yazılmış fake'ler ve httptest yeter. Bookstore'da stok kuralının tablo testi, sabit saat, mux üzerinden handler testi, Go 1.27'nin httptest.NewTestServer'ı ile synctest, negatif tutar hatasını bulan fuzz testi, generic kodun benchmark'ı, SQL Server'a giden entegrasyon testi, coverage ve go test -race.

Sürüm notu: Bu yazıdaki kodlar ve testler Go 1.27.4 ile derlenip çalıştırılmıştır; module'ün go satırı 1.26.0'dır ve testler Go 1.26.11 ile de geçer. httptest.NewTestServer Go 1.27 ile geldiği için onu kullanan test dosyası //go:build go1.27 ile ayrılmıştır. testing.B.Loop ve T.Context Go 1.24, testing/synctest Go 1.25 ile gelmiştir. Entegrasyon testi SQL Server 2025 ve go-mssqldb v1.11.2 ile çalıştırıldı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) fiyat tipi Money, 03'ten beri kuruşu int64 olarak tutuyor ve kendini şöyle yazdırıyordu:

// String tutarı insanlar için yazar: Money(34990) "349.90 TRY" olarak çıkar.
func (m Money) String() string {
    return fmt.Sprintf("%d.%02d TRY", m/100, m%100)
}

fmt.Println(Money(34990), Money(-150), Money(-5))

Kampanyalı bir siparişte iade tutarı negatif olabilir. Son satır ne yazdırır?

349.90 TRY -1.-50 TRY 0.-5 TRY

Go'da tam sayı bölmesi sıfıra doğru yuvarlıyor ve % bölünenin işaretini alıyor: -150/100 -1, -150%100 -50. Bu hata 03'ten beri kodda duruyordu. Ben bulmadım; bu yazının sonlarına doğru yazacağımız bir fuzz testi 0,1 saniyede buldu. Bu yazıda Go'nun testing package'ını görüyoruz: table-driven test, mock library'si olmadan fake, httptest, Go 1.27'de gelen httptest.NewTestServer ile synctest, fuzzing, benchmark ve coverage. Şimdiye kadar her davranışı elle çalıştırıp çıktısına baktık; artık bunları tekrarlanabilir hâle getiriyoruz.

testing Package'ı ve Table-Driven Test

Go'da test için ayrı bir framework kurmuyorsun. Adı _test.go ile biten dosyalar sadece go test sırasında derleniyor; func TestXxx(t *testing.T) imzalı her fonksiyon bir test. Assertion library'si de yok: koşulu if ile yazıp t.Errorf ya da t.Fatalf ile hata bildiriyorsun. İlki testi devam ettiriyor, ikincisi o testi orada bitiriyor.

xUnit'teki [Theory] + [InlineData]'nın karşılığı table-driven test: girdiler ve beklenen sonuçlar bir slice'ta, test de bu slice üzerinde dönen tek bir döngü. 05'ten beri Service.Place'te duran stok kuralını şöyle test ediyorum:

func TestPlaceStockRule(t *testing.T) {
    tests := []struct {
        name    string
        isbn    string
        qty     int
        wantErr error // nil: sipariş verilmeli
    }{
        {"less than stock", "9780441172719", 1, nil},
        {"exactly the stock", "9780441172719", 2, nil},
        {"one more than stock", "9780441172719", 3, ErrOutOfStock},
        {"sold out", "9780547928227", 1, ErrOutOfStock},
        {"unknown isbn", "9780000000000", 1, catalog.ErrBookNotFound},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            repo := &fakeRepo{}
            _, err := newTestService(t, repo).Place(t.Context(), "ada", []Line{{ISBN: tt.isbn, Quantity: tt.qty}})
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("Place(%s x%d) error = %v, want %v", tt.isbn, tt.qty, err, tt.wantErr)
            }
            if wantSaved := tt.wantErr == nil; (len(repo.saved) == 1) != wantSaved {
                t.Errorf("saved %d orders, want saved = %v", len(repo.saved), wantSaved)
            }
        })
    }
}

t.Run her satırı adı olan bir alt test yapıyor; go test -run 'TestPlaceStockRule/sold_out' ile tek satırı çalıştırabilirsin. t.Parallel() alt testleri aynı anda çalıştırıyor. Go 1.22'den önce bu döngüde tt := tt satırı şarttı, yoksa paralel alt testlerin hepsi son satırı görürdü; 03'te gördüğümüz döngü değişkeni değişikliği bu satırı gereksiz kıldı. t.Context() (Go 1.24) test bitince iptal edilen bir context veriyor. errors.Is(err, nil), err nil ise true döndüğü için "hata beklemiyoruz" satırı da aynı kontrolden geçiyor.

$ go test ./internal/orders -run TestPlaceStockRule -v
=== RUN   TestPlaceStockRule
=== RUN   TestPlaceStockRule/less_than_stock
=== PAUSE TestPlaceStockRule/less_than_stock
...
=== CONT  TestPlaceStockRule/unknown_isbn
--- PASS: TestPlaceStockRule (0.00s)
    --- PASS: TestPlaceStockRule/less_than_stock (0.00s)
    --- PASS: TestPlaceStockRule/sold_out (0.00s)
    --- PASS: TestPlaceStockRule/one_more_than_stock (0.00s)
    --- PASS: TestPlaceStockRule/exactly_the_stock (0.00s)
    --- PASS: TestPlaceStockRule/unknown_isbn (0.00s)
PASS
ok      bookstore/internal/orders    0.470s

Tablonun neye yaradığını görmek için kuralı bilerek bozdum: b.Stock < l.Quantity yerine <= yazdım. Stoktaki son kopyayı satamayan bu off-by-one hatası (bir eksik ya da bir fazla sayma hatası) elle denemede kolayca gözden kaçar:

--- FAIL: TestPlaceStockRule (0.00s)
    --- FAIL: TestPlaceStockRule/exactly_the_stock (0.00s)
        service_test.go:79: Place(9780441172719 x2) error = place order: "Dune" has 2 in stock, 2 requested: out of stock, want <nil>
--- FAIL: TestPlaceUsesClockAndStoreID (0.00s)
    service_test.go:92: place order: "Dune" has 2 in stock, 2 requested: out of stock
FAIL

Hata mesajı ne denendiğini, ne geldiğini ve ne beklendiğini söylüyor: Place(girdi) = sonuç, want beklenen. Go'nun kendi testlerinde de kullanılan bu biçim, assertion library'sinin ürettiği mesajın işini görüyor. Unit Test serisinin "xUnit v3'te Veri Odaklı Testler" yazısındaki TheoryData'nın Go'daki karşılığı bu anonim struct slice'ı.

Fake'ler ve Sabit Saat

Testteki Service ne veritabanına ne de gerçek kataloğa gidiyor. 04'te Repository interface'ini service'in yanında tanımlamıştık; şimdi bunun karşılığını alıyoruz. Test dosyasında elle yazılmış iki küçük tip yetiyor:

// fakeRepo testler için elle yazılmış bir Repository: mock library'si yok, sadece
// kendisine verileni hatırlayan bir struct.
type fakeRepo struct {
    saved   []*Order
    saveErr error
}

func (f *fakeRepo) NextID(context.Context) (string, error) {
    return fmt.Sprintf("ORD-%d", 1001+len(f.saved)), nil
}

func (f *fakeRepo) Save(_ context.Context, o *Order) error {
    if f.saveErr != nil {
        return f.saveErr
    }
    f.saved = append(f.saved, o)
    return nil
}

func (f *fakeRepo) ByID(context.Context, string) (*Order, error) {
    return nil, ErrNotFound
}

// fakeCatalog sabit bir kitap listesi sunar.
type fakeCatalog map[string]catalog.Book

func (c fakeCatalog) ByISBN(isbn string) (catalog.Book, error) {
    b, ok := c[isbn]
    if !ok {
        return catalog.Book{}, fmt.Errorf("isbn %s: %w", isbn, catalog.ErrBookNotFound)
    }
    return b, nil
}

var testBooks = fakeCatalog{
    "9780441172719": {ISBN: "9780441172719", Title: "Dune", Price: 34990, Stock: 2},
    "9780547928227": {ISBN: "9780547928227", Title: "The Hobbit", Price: 28950, Stock: 0},
}

// newTestService fake depolama ve sabit saatle bir Service döndürür.
func newTestService(t *testing.T, repo *fakeRepo) *Service {
    t.Helper()
    s := NewService(repo, testBooks)
    s.now = func() time.Time { return time.Date(2026, 11, 15, 9, 30, 0, 0, time.UTC) }
    return s
}

Moq ya da NSubstitute gibi bir library yok, kod üretimi de yok. Go'da interface'ler implicit olduğu için fakeRepo, Repository'nin adını anmadan onu karşılıyor; interface üç method'luk olduğu için fake de on beş satır. Unit Test serisinin "Test Double'lar" yazısındaki ayrımla fakeRepo hem fake hem spy: çağrıları gerçekten yapıyor ve neyin kaydedildiğini hatırlıyor. Go topluluğunda gomock ve testify/mock da kullanılıyor, ama interface küçük kaldıkça elle yazılan fake hem daha okunur hem de refactoring'de daha az kırılıyor.

Sabit saat için ayrı bir abstraction gerekmedi. Service'in 05'ten beri bir now func() time.Time field'ı var ve test dosyası package orders içinde olduğu için bu unexported field'a erişebiliyor. newTestService içindeki t.Helper(), bu yardımcıda bir hata olursa satır numarasının yardımcıyı değil onu çağıran testi göstermesini sağlıyor. Saat sabit olunca sipariş zamanını tam eşitlikle test edebiliyoruz:

func TestPlaceUsesClockAndStoreID(t *testing.T) {
    repo := &fakeRepo{}
    o, err := newTestService(t, repo).Place(t.Context(), "ada", []Line{{ISBN: "9780441172719", Quantity: 2}})
    if err != nil {
        t.Fatal(err)
    }
    want := time.Date(2026, 11, 15, 9, 30, 0, 0, time.UTC)
    if o.ID != "ORD-1001" || !o.CreatedAt.Equal(want) || o.Total() != 69980 {
        t.Errorf("got %s created %v total %v; want ORD-1001 created %v total 699.80 TRY", o.ID, o.CreatedAt, o.Total(), want)
    }
}

Burada == yerine Equal kullandım; time.Time içinde konum bilgisi ve monotonic clock okuması da taşıdığı için iki aynı an == ile farklı çıkabiliyor. Bu konuyu 16. yazıda ayrıca göreceğiz.

Test dosyası iki package adından birini alabiliyor. package orders (internal test) unexported her şeye erişiyor; package orders_test (external test) package'ı dışarıdan, sadece export edilmiş API'siyle görüyor. Handler testlerinde ikincisini kullandım: test, main'in yaptığı gibi httpapi.API'yi kuruyor.

Handler Testleri: httptest

net/http/httptest iki araç veriyor. httptest.NewRecorder bir http.ResponseWriter; handler'ı server açmadan çağırıp status'u, header'ları ve body'yi okuyorsun. ASP.NET Core'daki DefaultHttpContext ile handler test etmeye benziyor:

func TestGetBook(t *testing.T) {
    routes := newAPI().Routes() // r.PathValue("isbn")'i mux doldurur
    tests := []struct {
        path       string
        wantStatus int
        wantBody   string
    }{
        {"/api/books/9780441172719", http.StatusOK, `"title":"Dune"`},
        {"/api/books/9780000000000", http.StatusNotFound, "book not found"},
        {"/api/books/", http.StatusNotFound, "404 page not found"},
    }
    for _, tt := range tests {
        t.Run(tt.path, func(t *testing.T) {
            rec := httptest.NewRecorder()
            routes.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
            if rec.Code != tt.wantStatus || !strings.Contains(rec.Body.String(), tt.wantBody) {
                t.Errorf("GET %s = %d %q, want %d containing %q", tt.path, rec.Code, rec.Body, tt.wantStatus, tt.wantBody)
            }
        })
    }
}

İstek doğrudan handler'a değil mux'a gidiyor, çünkü r.PathValue("isbn")'i 09'da gördüğümüz gibi pattern eşleşmesi dolduruyor. Handler'ı tek başına çağırsaydın PathValue boş dönerdi. Tablonun son satırı bu yüzden işe yarıyor: /api/books/ hiçbir pattern'e uymuyor ve 404'ü handler değil mux veriyor.

İkinci araç httptest.NewServer: 127.0.0.1'de rastgele bir port'ta gerçek bir server açıyor. Middleware zinciri, header'lar ve body ile birlikte uçtan uca bir istek gerekiyorsa bunu kullanıyorsun:

func TestPlaceOrderEndToEnd(t *testing.T) {
    srv := httptest.NewServer(httpapi.Chain(newAPI().Routes(), httpapi.RequestID))
    defer srv.Close()

    body, err := os.Open("testdata/place_order.json") // go test, package'ın klasöründe çalışır
    if err != nil {
        t.Fatal(err)
    }
    defer body.Close()
    resp, err := srv.Client().Post(srv.URL+"/api/orders", "application/json", body)
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()

    var got struct {
        ID    string `json:"id"`
        Total int64  `json:"total"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
        t.Fatal(err)
    }
    if resp.StatusCode != http.StatusCreated || got.ID != "ORD-1001" || got.Total != 69980 {
        t.Errorf("got %d %+v, want 201 ORD-1001 total 69980", resp.StatusCode, got)
    }
    if loc := resp.Header.Get("Location"); loc != "/api/orders/ORD-1001" {
        t.Errorf("Location = %q", loc)
    }
    if resp.Header.Get("X-Request-ID") == "" {
        t.Error("X-Request-ID header is missing")
    }
}

Request body'si testdata/place_order.json'da. go aracı testdata adlı klasörleri package olarak görmüyor, go test de testi package'ın klasöründe çalıştırdığı için göreli yol her makinede doğru. xUnit'te dosyaları çıktı klasörüne kopyalatmak için yaptığın proje ayarının karşılığı bu kural.

synctest ve httptest.NewTestServer (Go 1.27)

08'de testing/synctest ile ödeme zaman aşımını test etmiştik: bubble içindeki kod sahte bir saat görüyor ve bütün goroutine'ler beklemeye geçince zaman ileri atlıyor. HTTP ile bu zordu, çünkü httptest.NewServer gerçek bir network soketi açıyor ve soketteki bekleme bubble'ın bilmediği bir şey. Go 1.27'deki httptest.NewTestServer varsayılan olarak bellekte çalışan bir network kullanıyor ve dokümanı bunun synctest ile kullanılabildiğini söylüyor. Server'ı test bitince kapatmak için Cleanup'ı da kendisi kaydediyor. Logging middleware'inin süreyi doğru yazdığını şöyle test ettim:

// TestLoggingDuration yavaş bir handler'ı synctest bubble'ı içinde çalıştırır. Sahte
// saat log'daki süreyi kesin yapar; NewTestServer (Go 1.27) bubble içinde çalışan
// bellek içi bir network üzerinden cevap verir.
func TestLoggingDuration(t *testing.T) {
    var buf bytes.Buffer
    log.SetOutput(&buf)
    log.SetFlags(0)
    t.Cleanup(func() { log.SetOutput(os.Stderr); log.SetFlags(log.LstdFlags) })

    synctest.Test(t, func(t *testing.T) {
        slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            time.Sleep(2 * time.Second) // hemen döner: sadece sahte saat ilerler
            w.Write([]byte("ok\n"))
        })
        srv := httptest.NewTestServer(t, httpapi.Chain(slow, httpapi.RequestID, httpapi.Logging))

        req, _ := http.NewRequest(http.MethodGet, "http://bookstore.test/slow", nil)
        req.Header.Set("X-Request-ID", "test-rid")
        resp, err := srv.Client().Do(req)
        if err != nil {
            t.Fatal(err)
        }
        resp.Body.Close()
        synctest.Wait() // Logging middleware'inin satırını bitirmesini bekle
    })

    if got, want := strings.TrimSpace(buf.String()), "GET /slow 200 3B 2s rid=test-rid"; got != want {
        t.Errorf("log line = %q, want %q", got, want)
    }
}
$ go test ./internal/httpapi -run TestLoggingDuration -v
=== RUN   TestLoggingDuration
--- PASS: TestLoggingDuration (0.00s)
PASS

Handler iki saniye uyuyor, test 0,00 saniyede bitiyor ve log satırındaki süre tam olarak 2s. Gerçek saatle bu test hem iki saniye sürerdi hem de her seferinde iki saniyeden biraz fazla, farklı bir değer yazardı. synctest.Wait(), bubble'daki diğer goroutine'ler (burada server'ın isteği işleyen goroutine'i) durana kadar bekliyor; bu sayede log satırı yazılmadan kontrol yapılmıyor.

Dosyanın ilk satırındaki //go:build go1.27, module'ün go satırı 1.26.0 olduğu için gerekli. Go 1.26 bu dosyayı hiç derlemiyor ve testi sadece "çalıştırılacak test yok" diye geçiyor:

$ GOTOOLCHAIN=go1.26.11 go test -v -run TestLoggingDuration ./internal/httpapi
testing: warning: no tests to run
PASS
ok      bookstore/internal/httpapi    0.121s [no tests to run]

Testte log.SetOutput ile global logger'ı değiştirdiğim için bu test t.Parallel() çağırmıyor; 13. yazıda slog'a geçince logger'ı handler'a parametre olarak vereceğiz ve bu global değişiklik kalkacak.

Fuzzing: Negatif Tutar Hatasını Bulmak

Table-driven test senin aklına gelen durumları dener. Fuzzing (rastgele ve değiştirilmiş girdilerle hata arama) ise aklına gelmeyenleri arıyor. Go 1.18'den beri go test'in içinde. 11'de Money veritabanına Decimal() metniyle gidip ParseMoney ile geri geliyordu; her tutarın bu gidiş dönüşten aynen çıkması gerekiyor. Fuzz testi tam olarak bu özelliği söylüyor:

// FuzzMoneyRoundTrip her tutarın Decimal ve ParseMoney'den aynen çıktığını kontrol eder;
// veritabanına her gidiş dönüşte olan da bu.
func FuzzMoneyRoundTrip(f *testing.F) {
    for _, seed := range []int64{0, 5, 34990, 100, math.MinInt64} {
        f.Add(seed)
    }
    f.Fuzz(func(t *testing.T, n int64) {
        m := Money(n)
        back, err := ParseMoney(m.Decimal())
        if err != nil || back != m {
            t.Fatalf("Money(%d).Decimal() = %q, ParseMoney gives %d, %v", n, m.Decimal(), back, err)
        }
    })
}

f.Add ile verilen seed'ler normal go test'te sıradan test gibi çalışıyor. -fuzz verince Go bu seed'leri değiştirerek yeni girdiler üretiyor:

$ go test ./internal/catalog -run '^$' -fuzz FuzzMoneyRoundTrip -fuzztime 20s -parallel 4
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 4 workers
fuzz: elapsed: 0s, execs: 5 (48/sec), new interesting: 0 (total: 4)
--- FAIL: FuzzMoneyRoundTrip (0.11s)
    --- FAIL: FuzzMoneyRoundTrip (0.00s)
        money_test.go:46: Money(-21).Decimal() = "0.-21", ParseMoney gives 0, "0.-21": invalid money amount

    Failing input written to testdata/fuzz/FuzzMoneyRoundTrip/00c6facf29213d38
    To re-run:
    go test -run=FuzzMoneyRoundTrip/00c6facf29213d38
FAIL

İlk saniye dolmadan negatif bir sayı buldu. Hatalı girdiyi testdata/fuzz/ altına bir dosya olarak yazdı (içeriği go test fuzz v1 ve int64(-21) satırları); bu dosyayı repoda tutuyorum ve normal go test onu her seferinde regression testi olarak çalıştırıyor. Düzeltme işareti ayrı tutmak:

// Decimal tutarı para birimi olmadan yazar: Money(34990) "349.90",
// Money(-150) "-1.50" olur.
func (m Money) Decimal() string {
    sign, abs := "", uint64(m)
    if m < 0 {
        sign, abs = "-", -uint64(m) // math.MinInt64 için de doğru
    }
    return fmt.Sprintf("%s%d.%02d", sign, abs/100, abs%100)
}

İşareti ayırınca bir sınır durumu daha çıktı: math.MinInt64'ün mutlak değeri int64'e sığmıyor. Bu yüzden mutlak değeri uint64'te hesapladım, ParseMoney'de de işareti strconv.ParseInt'e bıraktım ve math.MinInt64'ü seed'lere ekledim. Düzeltmeden sonra 30 saniyelik bir koşu yaklaşık 8,9 milyon girdi denedi ve hata bulmadı. Fuzzing'i CI'da her commit'te değil, ayrı ve zaman sınırlı bir job'da çalıştırmak yaygın; normal go test sadece seed'leri ve kaydedilmiş girdileri dener. Unit Test serisinin "Snapshot ve Property-based Testing" yazısındaki FsCheck'in Go'daki en yakın karşılığı bu.

Benchmark: Generic Kodun Bedeli Var mı?

06'da paging.Of[T]'yi yazarken generic kodun hızını 12'de ölçeceğimizi söylemiştim. Benchmark fonksiyonu BenchmarkXxx(b *testing.B); Go 1.24'teki b.Loop() döngüden önceki hazırlığı ölçüme katmıyor ve döngüdeki çağrının derleyici tarafından silinmesini engelliyor. Karşılaştırma için aynı işi yapan generic olmayan bir sürümü test dosyasına yazdım:

func ofBooks(items []book, page, size int) bookPage {
    total := len(items)
    start := min((page-1)*size, total)
    end := min(start+size, total)
    return bookPage{Items: items[start:end:end], Page: page, PageSize: size,
        TotalItems: total, TotalPages: (total + size - 1) / size}
}
func BenchmarkOfGeneric(b *testing.B) {
    bs := makeBooks(10_000)
    for b.Loop() { // Go 1.24: yukarıdaki hazırlık ölçülmez, döngü gövdesi optimize edilip silinmez
        p := Of(bs, 37, 20)
        sink += len(p.Items)
    }
}
$ go test -run '^$' -bench . -benchmem -cpu 4 -count 3 ./internal/paging
pkg: bookstore/internal/paging
BenchmarkOfGeneric-4    458796928    2.817 ns/op    0 B/op    0 allocs/op
BenchmarkOfGeneric-4    448979904    2.660 ns/op    0 B/op    0 allocs/op
BenchmarkOfGeneric-4    441203031    2.669 ns/op    0 B/op    0 allocs/op
BenchmarkOfBooks-4      439244882    2.889 ns/op    0 B/op    0 allocs/op
BenchmarkOfBooks-4      404147508    2.982 ns/op    0 B/op    0 allocs/op
BenchmarkOfBooks-4      381018705    3.043 ns/op    0 B/op    0 allocs/op
PASS

İki sürüm arasında ölçülebilir bir fark yok; iki tarafta da allocation sıfır. Go generic'leri tip parametresini GC (garbage collector; kullanılmayan belleği otomatik toplayan bileşen) açısından tipin biçimine, yani "GC shape"ine (pointer mı, boyutu ne) göre derliyor ve book gibi bir struct için özel kod üretiyor. Fark, tip parametresi üzerinden method çağrılan ve pointer tipleriyle kullanılan generic kodda çıkabiliyor; orada da tahmin yerine bu şekilde ölçmek gerekiyor. Ölçümleri karşılaştırmak için -count ile birkaç kez çalıştırıp benchstat aracını kullanmak, tek koşuya bakmaktan daha güvenilir.

Entegrasyon Testi, Coverage, vet ve CI

11'deki saat dilimi hatası fake'li bir unit test ile yakalanmazdı; hata driver ile SQL Server arasında. Bunun için sqlstore'a gerçek veritabanına giden bir test ekledim. Ortam değişkeni yoksa test kendini atlıyor, böylece go test ./... SQL Server olmayan bir makinede de geçiyor:

// openTestDB, BOOKSTORE_TEST_DB'deki veritabanına bağlanır; değişken yoksa
// testi atlar, böylece "go test ./..." SQL Server olmadan da çalışır.
func openTestDB(t *testing.T) *sql.DB {
    t.Helper()
    dsn := os.Getenv("BOOKSTORE_TEST_DB")
    if dsn == "" {
        t.Skip("BOOKSTORE_TEST_DB is not set")
    }
    db, err := sql.Open("sqlserver", dsn)
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { db.Close() })
    if err := sqlstore.EnsureSchema(t.Context(), db, catalog.Store{}.All()); err != nil {
        t.Fatal(err)
    }
    return db
}

Testin işe yaradığını görmek için Save'deki .UTC()'yi geçici olarak sildim:

--- FAIL: TestSaveAndLoad (0.15s)
    sqlstore_test.go:55: CreatedAt = 2026-11-15 05:22:24.737 +0000 UTC, want 2026-11-15 05:22:24.737359 +0300 +03 m=+0.127131584 (off by 2h59m59.999641s)
FAIL

Bütün kontroller bir arada, race detector ile ve coverage ile:

$ go vet ./... && go tool staticcheck ./...
$ BOOKSTORE_TEST_DB='sqlserver://...' go test -race ./...
ok      bookstore/internal/catalog    1.223s
ok      bookstore/internal/httpapi    1.261s
ok      bookstore/internal/orders     1.219s
ok      bookstore/internal/orders/sqlstore    1.490s
ok      bookstore/internal/paging     1.218s
ok      bookstore/labs/08-payment     1.214s
$ go test -cover ./internal/...
ok      bookstore/internal/catalog    0.201s    coverage: 34.6% of statements
ok      bookstore/internal/httpapi    0.224s    coverage: 36.8% of statements
ok      bookstore/internal/orders     0.198s    coverage: 71.9% of statements
        bookstore/internal/orders/memstore      coverage: 0.0% of statements
ok      bookstore/internal/orders/sqlstore    0.481s    coverage: 73.2% of statements
ok      bookstore/internal/paging     0.207s    coverage: 100.0% of statements

vet ve staticcheck çıktı vermediği için temiz. memstore'un %0 görünmesi yanıltıcı: handler testleri onu kullanıyor, ama coverage varsayılan olarak sadece testin kendi package'ını sayıyor. -coverpkg=./internal/... bütün package'ları birlikte ölçüyor; -coverprofile=cover.out ve go tool cover -html=cover.out de satır satır gösteriyor. Coverage'ı bir hedef olarak değil, test edilmemiş bir yolu bulmak için kullan. go tool cover -func, catalog'daki %34,6'nın sebebini gösteriyor: kataloğun sorgu fonksiyonları ve Money'nin Scan/Value method'ları bu package'ın testlerinde hiç çalışmıyor; Scan sadece sqlstore'un entegrasyon testinden geçiyor.

CI'da (Continuous Integration; her commit'te otomatik build ve test) çalıştırdığım satırlar yukarıdakilerle aynı: go vet ./..., go tool staticcheck ./... ve go test -race ./.... Race detector cgo gerektiriyor; Linux CI runner'larında ek bir şey kurmadan çalışıyor, Windows'ta bir C derleyicisi (örneğin mingw-w64) gerekiyor. Pipeline dosyasının kendisi CI/CD serilerinin konusu; bu yazıda bir CI dosyası çalıştırmadım.

Üretimde Dikkat

go test sonuçları cache'liyor: kod ve girdiler değişmediyse ikinci koşuda (cached) yazıyor. Test cache'i okuduğun ortam değişkenlerini ve dosyaları da hesaba katıyor, bu yüzden BOOKSTORE_TEST_DB'yi değiştirmek testi yeniden çalıştırıyor; ama veritabanındaki veri değiştiğinde Go bunu bilemez. Entegrasyon testlerini CI'da -count=1 ile çalıştırmak cache'i kapatıyor.

Entegrasyon testleri ortak bir veritabanında birbirini etkileyebilir. TestSaveAndLoad başta Emma'nın stoğunu kendisi 2'ye ayarlıyor, yani önceki koşulardan kalan veriye güvenmiyor. Daha büyük bir test setinde her test kendi verisini oluşturmalı ya da her koşu kendi geçici veritabanını açmalı.

Benchmark sonuçları makineye, yüke ve CPU frekansına bağlı. Sayıları başka bir makinedeki sayılarla değil, aynı makinede önceki ve sonraki hâliyle karşılaştır.

Sık Düşülen Tuzaklar

Table-driven testte t.Run kullanmamak. İlk hatalı satırda t.Fatal bütün tabloyu durdurur ve hangi satırın bozulduğu adla görünmez.

Goroutine içinden t.Fatal çağırmak. FailNow sadece testin kendi goroutine'inde çalışıyor; başka bir goroutine'de çağrılırsa test beklenmedik şekilde davranır. go vet'in testinggoroutine kontrolü bunu yakalıyor; goroutine'de t.Error kullan ya da hatayı bir channel ile geri gönder.

Handler'ı mux olmadan test etmek. Path wildcard'ları sadece mux eşleşmesinde dolduruluyor; doğrudan çağrılan handler boş PathValue görür.

Gerçek saate bağlı testler. time.Sleep ile beklemek ya yavaş ya da kararsız testler üretir; saati enjekte et ya da synctest kullan.

Fuzz'un bulduğu dosyayı silmek. testdata/fuzz altındaki girdi, hatanın geri gelmediğini kanıtlayan tek şey.

Küçük Bir Deneme

★☆☆ TestPlaceStockRule'a adet 0 ve negatif adet için iki satır ekle. Hangi hatayı bekliyorsun ve bunu errors.Is ile mi, ValidationErrors ile mi kontrol etmek gerekiyor?

★★☆ GET /api/orders/{id} için table-driven bir handler testi yaz: önce POST ile bir sipariş oluştur, sonra var olan ve olmayan ID'yi iste. 404 cevabının Content-Type'ı application/problem+json mı?

★★★ positiveInt için bir fuzz testi yaz: true döndüğü her girdi için sonucun 1 ya da daha büyük olduğunu ve strconv.Itoa(n)'nin girdiyle aynı olduğunu kontrol et. Fuzzer "+5" ya da "007" gibi girdiler buluyor mu? Bunlar API için hata mı?

Testler, coverage ve race detector hazır; kod değiştirdiğimizde neyin bozulduğunu artık hemen görebiliyoruz. Bir sonraki yazıda servisi production'a taşıyoruz: log/slog ile structured logging ve log'da request ID, listen adresini ve CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atma kuralları) origin'ini koddan çıkarıp configuration'a taşımak, graceful shutdown, health check endpoint'leri, ayrı bir port'ta net/http/pprof ve 08'de gördüğümüz goroutineleak profili, GOMAXPROCS, GOMEMLIMIT ve Green Tea GC, ve küçük bir container image'ı.