Uji Berbasis Tabel Paralel dalam Go

Percepat tes Go dengan eksekusi paralel

Konten Halaman

Table-driven tests adalah pendekatan idiomatic Go untuk menguji beberapa skenario secara efisien. Ketika dikombinasikan dengan eksekusi paralel menggunakan t.Parallel(), Anda dapat secara dramatis mengurangi waktu eksekusi suite pengujian, terutama untuk operasi I/O-bound.

Namun, pengujian paralel memperkenalkan tantangan unik seputar kondisi race dan isolasi pengujian yang memerlukan perhatian yang hati-hati.

Parallel Table-Driven Tests in Go - golang racing conditions

Memahami Eksekusi Pengujian Paralel

Go’s package pengujian menyediakan dukungan bawaan untuk eksekusi pengujian paralel melalui metode t.Parallel(). Ketika sebuah pengujian memanggil t.Parallel(), ini memberi sinyal ke runner pengujian bahwa pengujian ini dapat aman dijalankan secara bersamaan dengan pengujian paralel lainnya. Ini sangat kuat ketika dikombinasikan dengan pengujian berbasis tabel, di mana Anda memiliki banyak kasus pengujian independen yang dapat dieksekusi secara bersamaan.

Parallelisme default dikontrol oleh GOMAXPROCS, yang biasanya sama dengan jumlah inti CPU di mesin Anda. Anda dapat menyesuaikan ini dengan flag -parallel: go test -parallel 4 membatasi pengujian paralel menjadi 4, terlepas dari jumlah CPU Anda. Ini berguna untuk mengontrol penggunaan sumber daya atau ketika pengujian memiliki persyaratan konkurensi tertentu.

Untuk pengembang baru di Go pengujian, memahami dasar-dasarnya sangat penting. Panduan kami tentang best practices Go unit testing mencakup pengujian berbasis tabel, subtest, dan dasar-dasar package pengujian yang membentuk fondasi untuk eksekusi paralel.

Pola Dasar Pengujian Berbasis Tabel Paralel

Berikut adalah pola yang benar untuk pengujian berbasis tabel paralel:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addition", 2, 3, "+", 5, false},
        {"subtraction", 5, 3, "-", 2, false},
        {"multiplication", 4, 3, "*", 12, false},
        {"division", 10, 2, "/", 5, false},
        {"division by zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        tt := tt // Capture loop variable
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Enable parallel execution
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

Baris kritis adalah tt := tt sebelum t.Run(). Ini menangkap nilai saat ini dari variabel loop, memastikan setiap subtest paralel beroperasi pada salinan sendiri dari data kasus pengujian.

Masalah Penangkapan Variabel Loop

Ini adalah salah satu kesalahan paling umum ketika menggunakan t.Parallel() dengan pengujian berbasis tabel. Dalam Go, variabel loop tt dibagikan di seluruh iterasi. Ketika subtest berjalan secara paralel, mereka mungkin semua merujuk pada variabel tt yang sama, yang akan ditimpa seiring berlangsungnya loop. Ini menyebabkan kondisi race dan kegagalan pengujian yang tidak terduga.

Salah (kondisi race):

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Semua subtest mungkin melihat nilai tt yang sama!
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

Benar (variabel yang ditangkap):

for _, tt := range tests {
    tt := tt // Capture the loop variable
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Setiap subtest memiliki salinan sendiri dari tt
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

Penugasan tt := tt menciptakan variabel baru dengan lingkup iterasi loop, memastikan setiap goroutine memiliki salinan sendiri dari data kasus pengujian.

Memastikan Kemandirian Pengujian

Agar pengujian paralel berjalan dengan benar, setiap pengujian harus sepenuhnya mandiri. Mereka tidak boleh:

  • Berbagi state global atau variabel
  • Memodifikasi sumber daya bersama tanpa sinkronisasi
  • Bergantung pada urutan eksekusi
  • Mengakses file, database, atau sumber daya jaringan yang sama tanpa koordinasi

Contoh pengujian paralel mandiri:

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"valid email", "user@example.com", false},
        {"invalid format", "not-an-email", true},
        {"missing domain", "user@", true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            err := ValidateEmail(tt.email)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", 
                    tt.email, err, tt.wantErr)
            }
        })
    }
}

Setiap kasus pengujian beroperasi pada data input sendiri tanpa state bersama, membuatnya aman untuk eksekusi paralel.

Mendeteksi Kondisi Race

Go menyediakan detektor race yang kuat untuk menangkap data race dalam pengujian paralel. Selalu jalankan pengujian paralel Anda dengan flag -race selama pengembangan:

go test -race ./...

Detektor race akan melaporkan setiap akses bersamaan ke memori tanpa sinkronisasi yang tepat. Ini sangat penting untuk menangkap bug halus yang mungkin hanya muncul di bawah kondisi timing tertentu.

Contoh kondisi race:

var counter int // State bersama - BERBAHAYA!

func TestIncrement(t *testing.T) {
    tests := []struct {
        name string
        want int
    }{
        {"test1", 1},
        {"test2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            counter++ // KONDISI RACE!
            if counter != tt.want {
                t.Errorf("counter = %d, want %d", counter, tt.want)
            }
        })
    }
}

Menjalankan ini dengan -race akan mendeteksi modifikasi bersamaan terhadap counter. Perbaikannya adalah membuat setiap pengujian mandiri dengan menggunakan variabel lokal alih-alih state bersama.

Manfaat Kinerja

Eksekusi paralel dapat secara signifikan mengurangi waktu eksekusi suite pengujian. Kecepatan peningkatan bergantung pada:

  • Jumlah inti CPU: Semakin banyak inti, semakin banyak pengujian yang dapat dijalankan secara bersamaan
  • Karakteristik pengujian: Pengujian I/O-bound lebih banyak mendapatkan manfaat dibandingkan pengujian CPU-bound
  • Jumlah pengujian: Suite pengujian yang lebih besar mendapatkan penghematan waktu yang lebih besar

Mengukur kinerja:

# Eksekusi berurutan
go test -parallel 1 ./...

# Eksekusi paralel (default)
go test ./...

# Paralelisme khusus
go test -parallel 8 ./...

Untuk suite pengujian dengan banyak operasi I/O (query database, permintaan HTTP, operasi file), Anda sering kali dapat mencapai peningkatan kecepatan 2-4x pada sistem multi-core modern. Pengujian CPU-bound mungkin mendapatkan manfaat yang lebih sedikit karena persaingan untuk sumber daya CPU.

Mengontrol Paralelisme

Anda memiliki beberapa opsi untuk mengontrol eksekusi pengujian paralel:

1. Batasi jumlah maksimal pengujian paralel:

go test -parallel 4 ./...

2. Tetapkan GOMAXPROCS:

GOMAXPROCS=2 go test ./...

3. Eksekusi paralel selektif:

Hanya tandai pengujian tertentu dengan t.Parallel(). Pengujian tanpa panggilan ini berjalan secara berurutan, yang berguna ketika beberapa pengujian harus berjalan dalam urutan tertentu atau berbagi sumber daya.

4. Eksekusi paralel bersyarat:

func TestExpensive(t *testing.T) {
    if testing.Short() {
        t.Skip("melewatkan pengujian mahal dalam mode pendek")
    }
    t.Parallel()
    // Logika pengujian mahal
}

Pola Umum dan Best Practices

Pola 1: Setup Sebelum Eksekusi Paralel

Jika Anda membutuhkan setup yang dibagikan di seluruh kasus pengujian, lakukan sebelum loop:

func TestWithSetup(t *testing.T) {
    // Kode setup berjalan sekali, sebelum eksekusi paralel
    db := setupTestDatabase(t)
    defer db.Close()

    tests := []struct {
        name string
        id   int
    }{
        {"user1", 1},
        {"user2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Setiap pengujian menggunakan db secara independen
            user := db.GetUser(tt.id)
            // Logika pengujian...
        })
    }
}

Pola 2: Setup Per Pengujian

Untuk pengujian yang membutuhkan setup terisolasi, lakukan di dalam setiap subtest:

func TestWithPerTestSetup(t *testing.T) {
    tests := []struct {
        name string
        data string
    }{
        {"test1", "data1"},
        {"test2", "data2"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Setiap pengujian mendapatkan setup sendiri
            tempFile := createTempFile(t, tt.data)
            defer os.Remove(tempFile)
            // Logika pengujian...
        })
    }
}

Pola 3: Campuran Berurutan dan Paralel

Anda dapat mencampur pengujian berurutan dan paralel dalam file yang sama:

func TestSequential(t *testing.T) {
    // Tidak ada t.Parallel() - berjalan berurutan
    // Baik untuk pengujian yang harus berjalan dalam urutan tertentu
}

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"test1"}, {"test2"}}
    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Ini berjalan secara paralel
        })
    }
}

Kapan Tidak Harus Menggunakan Eksekusi Paralel

Eksekusi paralel tidak selalu sesuai. Hindari penggunaannya ketika:

  1. Pengujian berbagi state: Variabel global, singleton, atau sumber daya bersama
  2. Pengujian memodifikasi file bersama: File sementara, database pengujian, atau file konfigurasi
  3. Pengujian bergantung pada urutan eksekusi: Beberapa pengujian harus berjalan sebelum yang lain
  4. Pengujian sudah cepat: Biaya paralelisasi mungkin melebihi manfaatnya
  5. Keterbatasan sumber daya: Pengujian mengonsumsi terlalu banyak memori atau CPU ketika diparalelisasi

Untuk pengujian terkait database, pertimbangkan penggunaan rollback transaksi atau database pengujian terpisah per pengujian. Panduan kami tentang pola database multi-tenant di Go mencakup strategi isolasi yang berfungsi baik dengan pengujian paralel.

Lanjutan: Pengujian Kode Konkuren

Ketika menguji kode konkuren itu sendiri (bukan hanya menjalankan pengujian secara paralel), Anda membutuhkan teknik tambahan:

func TestConcurrentOperation(t *testing.T) {
    tests := []struct {
        name      string
        goroutines int
    }{
        {"2 goroutines", 2},
        {"10 goroutines", 10},
        {"100 goroutines", 100},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            var wg sync.WaitGroup
            results := make(chan int, tt.goroutines)
            
            for i := 0; i < tt.goroutines; i++ {
                wg.Add(1)
                go func() {
                    defer wg.Done()
                    results <- performOperation()
                }()
            }
            
            wg.Wait()
            close(results)
            
            // Verifikasi hasil
            count := 0
            for range results {
                count++
            }
            if count != tt.goroutines {
                t.Errorf("expected %d results, got %d", tt.goroutines, count)
            }
        })
    }
}

Selalu jalankan pengujian seperti ini dengan -race untuk mendeteksi data race dalam kode yang diuji.

Integrasi dengan CI/CD

Pengujian paralel terintegrasi dengan mulus dengan pipeline CI/CD. Sebagian besar sistem CI menyediakan beberapa inti CPU, membuat eksekusi paralel sangat bermanfaat:

# Contoh GitHub Actions
- name: Run tests
  run: |
    go test -race -coverprofile=coverage.out -parallel 4 ./...
    go tool cover -html=coverage.out -o coverage.html    

Flag -race sangat penting dalam CI untuk menangkap bug konkurensi yang mungkin tidak muncul dalam pengembangan lokal.

Debugging Gagalnya Pengujian Paralel

Ketika pengujian paralel gagal secara tidak terduga, debugging bisa menjadi tantangan:

  1. Jalankan dengan -race: Identifikasi data race
  2. Kurangi paralelisme: go test -parallel 1 untuk melihat apakah kegagalan hilang
  3. Jalankan pengujian spesifik: go test -run TestName untuk mengisolasi masalah
  4. Tambahkan logging: Gunakan t.Log() untuk melacak urutan eksekusi
  5. Periksa state bersama: Cari variabel global, singleton, atau sumber daya bersama

Jika pengujian berhasil berurutan tetapi gagal secara paralel, kemungkinan besar Anda memiliki masalah kondisi race atau state bersama.

Contoh Nyata: Pengujian Handler HTTP

Berikut adalah contoh praktis pengujian handler HTTP secara paralel:

func TestHTTPHandlers(t *testing.T) {
    router := setupRouter()
    
    tests := []struct {
        name       string
        method     string
        path       string
        statusCode int
    }{
        {"GET users", "GET", "/users", 200},
        {"GET user by ID", "GET", "/users/1", 200},
        {"POST user", "POST", "/users", 201},
        {"DELETE user", "DELETE", "/users/1", 204},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            router.ServeHTTP(w, req)
            
            if w.Code != tt.statusCode {
                t.Errorf("expected status %d, got %d", tt.statusCode, w.Code)
            }
        })
    }
}

Setiap pengujian menggunakan httptest.NewRecorder(), yang menciptakan recorder respons terisolasi, membuat pengujian ini aman untuk eksekusi paralel.

Kesimpulan

Eksekusi paralel dari pengujian berbasis tabel adalah teknik yang kuat untuk mengurangi waktu eksekusi suite pengujian di Go. Kunci keberhasilan adalah memahami kebutuhan penangkapan variabel loop, memastikan kemandirian pengujian, dan menggunakan detektor race untuk menangkap masalah konkurensi secara dini.

Ingatlah:

  • Selalu tangkap variabel loop: tt := tt sebelum t.Parallel()
  • Pastikan pengujian mandiri tanpa state bersama
  • Jalankan pengujian dengan -race selama pengembangan
  • Kontrol paralelisme dengan flag -parallel ketika diperlukan
  • Hindari eksekusi paralel untuk pengujian yang berbagi sumber daya

Dengan mengikuti praktik-praktik ini, Anda dapat aman memanfaatkan eksekusi paralel untuk mempercepat suite pengujian Anda sambil mempertahankan keandalan. Untuk lebih banyak pola pengujian Go, lihat panduan komprehensif kami tentang Go unit testing, yang mencakup pengujian berbasis tabel, mocking, dan teknik pengujian penting lainnya.

Ketika membangun aplikasi Go yang lebih besar, praktik pengujian ini berlaku di berbagai domain. Misalnya, ketika membangun aplikasi CLI dengan Cobra & Viper, Anda akan menggunakan pola pengujian paralel yang serupa untuk menguji handler perintah dan parsing konfigurasi.

Tautan Berguna

Sumber Daya Eksternal