39 / 73

Integratsiya va tizim testlari

Unit testning butun qiymati izolyatsiyaga tayanadi: u tez, deterministik, tarmoqsiz, diskdan foydalanmaydi va boshqa hech bir test bilan holat ulashmaydi. Aynan shu narsa uni butun bir xatolar toifasiga ko'r qiladi — haqiqiy tizimning ikki qismi aslida uchrashadigan joyda paydo bo'ladigan xatolarga. Izolyatsiyada JSON'ni to'g'ri unmarshal qiladigan handler o'zi tayanadigan header'ni olib tashlaydigan router'ga ulangan zahoti muvaffaqiyatsiz bo'lishi mumkin; mock'ga qarshi to'g'ri so'rov beradigan repository haqiqiy ma'lumotlar bazasining aslida qulflash xatti-harakatiga qarshi buzilishi mumkin. Integratsiya va tizim testlari shu tikuvni qoplash uchun mavjud: ular unit testning tezligi va determinizmining bir qismidan ataylab voz kechib, qismlar aslida bir-biriga mos kelishiga ishonch hosil qiladi.

HTTP'ni tarmoqsiz testlash

net/http/httptest paketi Go tilida HTTP handler'larni testlashda haqiqiy tarmoq portini (soket) ochishga deyarli ehtiyoj qoldirmaydi. httptest.NewRecorder orqali yozilgan barcha ma'lumotlarni xotirada saqlab qoluvchi soxta http.ResponseWriter yaratiladi, httptest.NewRequest esa tarmoqqa murojaat qilmasdan bevosita *http.Request obyektini tuzadi. Bu vositalar handler funksiyasini oddiy Go funksiyasi kabi chaqirib, uning qaytargan javobini tez va ishonchli tekshirish imkonini beradi:

example.go
func userHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "Alice"})
}

func TestUserHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/user", nil)
    w := httptest.NewRecorder()

    userHandler(w, req)

    if w.Code != http.StatusOK {
        t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
    }
    var got struct {
        ID   int    `json:"id"`
        Name string `json:"name"`
    }
    if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
        t.Fatalf("decoding response: %v", err)
    }
    if got.Name != "Alice" {
        t.Errorf("name = %q, want %q", got.Name, "Alice")
    }
}

Bu hamon texnik jihatdan unit test — hech qanday soket ochilmadi — lekin u handler'ni production trafik ishlatadigan aynan o'sha interface'lar (http.ResponseWriter, *http.Request) orqali sinaydi, bu qandaydir ichki funksiyani to'g'ridan-to'g'ri chaqirishdan sezilarli kuchliroq kafolat. Xuddi shu andoza POST tanalariga, autentifikatsiya kabi middleware'ni sinash uchun maxsus header'larga va metodlar hamda yo'llar bo'ylab table-driven holatlarga tabiiy ravishda kengayadi:

example.go
func TestAuthMiddleware(t *testing.T) {
    protected := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))

    tests := []struct {
        name       string
        authHeader string
        wantStatus int
    }{
        {"valid token", "Bearer good-token", http.StatusOK},
        {"missing header", "", http.StatusUnauthorized},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(http.MethodGet, "/", nil)
            if tt.authHeader != "" {
                req.Header.Set("Authorization", tt.authHeader)
            }
            w := httptest.NewRecorder()
            protected.ServeHTTP(w, req)
            if w.Code != tt.wantStatus {
                t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
            }
        })
    }
}

Test haqiqiy tinglovchi serverni talab qilganda — chunki sinalayotgan kod so'rovlarni qabul qiladigan handler emas, haqiqiy so'rovlar yuboradigan http.Client bo'lgani uchun — httptest.NewServer uni OS tayinlagan lokal portda ishga tushiradi va sizga uning URL'ini beradi:

example.go
func TestClientAgainstServer(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("pong"))
    }))
    defer srv.Close() // doim yoping, aks holda listener testdan tashqariga sizib chiqadi

    resp, err := http.Get(srv.URL + "/ping")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        t.Errorf("status = %d, want 200", resp.StatusCode)
    }
}

httptest.NewTLSServer xuddi shu g'oya, faqat TLS ustida. U srv.Client() bilan juftlashadi: bu server o'zi imzolagan test sertifikatiga ishonish uchun allaqachon sozlangan *http.Client qaytaradi va testni o'tkazish uchun sertifikat tekshiruvini global o'chirib qo'yishdan saqlaydi.

Tez testlarni sekinlaridan ajratish

Har bir test standart go test ishga tushirishiga tegishli emas. Haqiqiy Postgres nusxasiga tegadigan yoki uchinchi tomon API'sini chaqiradigan to'plam qimmatli, lekin u sekin va shuningdek poyezdda oflayn turgan noutbukda mavjud bo'lmagan tashqi bog'liqliklarga ega. Goning build constraint'lari bunday fayllarni standart build'dan butunlay chiqarib tashlash va faqat tag aniq so'ralganda qo'shish imkonini beradi.

Zamonaviy sintaksis — //go:build izohi: u o'zining alohida qatorida bo'lishi va paket bandidan oldin bo'sh qator bilan davom etishi kerak:

example.go
//go:build integration

package repository

import (
    "database/sql"
    "testing"
)

func TestUserRepository_Integration(t *testing.T) {
    db, err := sql.Open("postgres", testDBURL)
    if err != nil {
        t.Fatalf("connecting to test database: %v", err)
    }
    defer db.Close()
    // haqiqiy ma'lumotlar bazasiga qarshi haqiqiy so'rovlarni bajarish
}
bash
go test ./...                  # bu fayl chiqarib tashlanadi
go test -tags=integration ./... # bu fayl kiritiladi

Mavjud kodda eski // +build integration shaklini ham uchratasiz. U Go 1.17 dan oldin yagona sintaksis edi va moslik uchun bir necha reliz davomida //go:build bilan yonma-yon qo'llab-quvvatlandi, lekin gofmt Go 1.17 dan beri uni avtomatik ravishda yangi shaklga qayta yozadi, va yangi kod faqat //go:builddan foydalanishi kerak. Build tag'lar mantiqiy ifodalar bilan birikadi, shuning uchun fayl bir tagni talab qilib, boshqasini chiqarib tashlashi mumkin:

example.go
//go:build integration && !short

package repository

Bundan kelib chiqadigan amaliy kelishuv — uch bosqichli bo'linish: tag'siz tez unit testlar, har go testda va har commit'da ishlaydi; haqiqiy ma'lumotlar bazasi yoki fayl tizimi kerak bo'lgan testlar uchun integration tag'i, CI'da va talab bo'yicha lokal ishlaydi; ba'zan butun joylashtirilgan tizimni sinaydigan testlar uchun e2e tag'i, kamroq ishlaydi va odatda faqat tag'ga tayanish o'rniga muhit tekshiruvi bilan ham to'siladi:

example.go
//go:build e2e

func TestFullCheckoutFlow(t *testing.T) {
    if os.Getenv("CI") == "" {
        t.Skip("end-to-end tests only run in CI")
    }
    apiURL := os.Getenv("API_URL")
    if apiURL == "" {
        t.Fatal("API_URL must be set for e2e tests")
    }
    // haqiqiy, joylashtirilgan tizimni HTTP orqali boshqarish
}

Tizim testlarini yaxshi qurish

Tizim yoki end-to-end test odatda haqiqiy foydalanuvchi oqimiga o'xshash biror narsani bajarishdan oldin bir nechta hamkorni — ma'lumotlar bazasi, haqiqiy handler'larga ulangan HTTP server, ba'zan message queue — ishga tushirishi kerak. Bu sozlashni test tanasiga sochilgan tekis defer chaqiruvlari ketma-ketligi o'rniga, test o'rtasida muvaffaqiyatsiz bo'lsa ham tozalash bajarilishini kafolatlash uchun t.Cleanupdan foydalanadigan yordamchilarga chiqaring:

example.go
func newIntegrationServer(t *testing.T) *httptest.Server {
    t.Helper()
    db := newTempDB(t) // o'zi ham o'zining t.Cleanup'ini ro'yxatdan o'tkazadi
    app := NewApp(db)
    srv := httptest.NewServer(app.Handler())
    t.Cleanup(srv.Close)
    return srv
}

func TestCheckoutFlow(t *testing.T) {
    srv := newIntegrationServer(t)
    client := srv.Client()

    t.Run("create order", func(t *testing.T) {
        resp, err := client.Post(srv.URL+"/orders", "application/json", strings.NewReader(`{"item":"book"}`))
        if err != nil {
            t.Fatalf("creating order: %v", err)
        }
        defer resp.Body.Close()
        if resp.StatusCode != http.StatusCreated {
            t.Fatalf("status = %d, want 201", resp.StatusCode)
        }
    })

    t.Run("retrieve order", func(t *testing.T) {
        resp, err := client.Get(srv.URL + "/orders/1")
        if err != nil {
            t.Fatalf("fetching order: %v", err)
        }
        defer resp.Body.Close()
        if resp.StatusCode != http.StatusOK {
            t.Errorf("status = %d, want 200", resp.StatusCode)
        }
    })
}

Subtest'larni shunday zanjirlash — avval yarating, keyin oling — bir xil ota-test ichida keyingi subtest'larning avvalgilariga bog'liq bo'lishi oqilona bo'lgan kam sonli joylardan biri, chunki ular mustaqil holatlar emas, ataylab bitta foydalanuvchi sessiyasini modellashtiradi. Bu oddiy unit testlar uchun mustaqillik qoidasidan boshqacha vaziyat: bu yerda ketma-ketlikning o'zi sinalayotgan narsaning bir qismi.

Haqiqiy bog'liqlik mavjud bo'lmaganda yoki har safar ishga tushirish uchun juda sekin bo'lganda (masalan, uchinchi tomon to'lov API'si), dependency injection orqali ulangan xuddi shu interface'ning fake amalga oshirilishi odatda mijozni transport qatlamida mock qilishdan yaxshiroq kelishuv. Chaqiruvlarni kuzatadigan va sozlanadigan javoblarni qaytaradigan fake to'lov processori haqida mulohaza yuritish ham osonroq, va haqiqiy mijozning aloqasiz implementatsiya tafsiloti o'zgarganda buzilishga ham chidamliroq:

example.go
type fakePaymentProcessor struct {
    charges []Charge
    failNext bool
}

func (f *fakePaymentProcessor) Charge(ctx context.Context, c Charge) error {
    if f.failNext {
        f.failNext = false
        return errors.New("card declined")
    }
    f.charges = append(f.charges, c)
    return nil
}

Bu klassik test-double ma'nosidagi stub yoki fake — production kodingiz bog'liq bo'lgan interface ortidagi yengil o'rinbosar, oldindan yozilgan kutilmalarga ega mocking-framework obyekti emas. Uni o'zingiz yozdingizmi yoki hamkasbingiz olti oy oldin yozdimi, baribir bir xil o'qiladi, bu esa uzoq muddatda mocking kutubxonasi beradigan kichik qulaylikdan qimmatroq.

Tez-tez muvaffaqiyatsiz bo'ladigan tizim testi unit testga qaraganda ko'proq foydali dalil qoldiradi: ushlangan so'rov/javob juftligi, server log'i, muvaffaqiyatsizlik daqiqasidagi ma'lumotlar bazasi dumpi. Bu dalilni chiqishda t.TempDir() tozalaydigan vaqtinchalik faylga yozsangiz, uni yo'qotib qo'yish oson. Go 1.26 t.ArtifactDir() muvaffaqiyatsiz tizim testlariga shu dalilni qo'yish uchun haqiqiy joy beradi: bu testning qolgan vaqtinchalik holati bilan g'oyib bo'lish o'rniga -outputdir ostida saqlanadigan (-artifacts flagi bilan doimiy qilinadigan) katalog. Shu tufayli CI muvaffaqiyatsizligi shunchaki stack trace bilan emas, haqiqiy javob tanasi yoki log fayli biriktirilgan holda keladi.

httptest va build-tag'li integratsiya testlari jarayondan hech qachon chiqmaydigan unit testlar bilan joylashtirilgan tizimni sinaydigan to'liq end-to-end testlar orasidagi keng hudud uchun bosqichma-bosqich vositalar to'plamini beradi. Bu har bir go test ishga tushirilishida to'liq joylashtirish uchun to'lamasdan, haqiqiy komponentlarni bir-biriga qarshi tekshirish imkonini beradi. To'g'rilik endi eng kichik funksiyadan komponentlararo oqimlargacha yoritilgach, keyingi bob Go dizaynining butunlay boshqa o'qiga o'tadi: concurrency, ya'ni "bir vaqtda bir nechta narsa sodir bo'lishi"ni tilga qo'shilgan kutubxona emas, birinchi darajali qismiga aylantiradigan goroutine'lar va channel'lardan boshlab.