A.59. Table-Driven Test
Table-driven test adalah teknik penulisan test di Go menggunakan tabel data (slice of struct) sebagai kumpulan test case. Dengan teknik ini, banyak skenario pengujian bisa ditulis dalam satu fungsi test yang ringkas dan mudah dikembangkan.
Teknik ini juga direkomendasikan secara resmi oleh tim Go. Baca lebih lanjut di https://go.dev/wiki/TableDrivenTests.
A.59.1. Masalah dengan Test Konvensional
Pada chapter sebelumnya (A.58. Unit Test), kita sudah belajar menulis test seperti ini:
func TestHitungVolume(t *testing.T) {
kubus := Kubus{4}
if kubus.Volume() != 64 {
t.Errorf("Volume salah: dapat %f, seharusnya 64", kubus.Volume())
}
}
Masalahnya, kalau kita ingin mengetes banyak skenario (sisi = 0, sisi desimal, sisi besar, dan lainnya), kita harus menulis banyak fungsi test terpisah. Kode menjadi panjang dan repetitif, padahal logika pengujiannya sama persis.
A.59.2. Solusi: Table-Driven Test
Dengan table-driven test, semua test case didefinisikan dalam sebuah slice, lalu diiterasikan satu per satu. Ini menghilangkan duplikasi dan memudahkan penambahan skenario baru cukup dengan menambah satu baris ke tabel.
Contoh berikut melanjutkan project dari chapter A.58. Struct Kubus dan method-nya sudah ada di main.go:
package main
import "math"
type Kubus struct {
Sisi float64
}
func (k Kubus) Volume() float64 {
return math.Pow(k.Sisi, 3)
}
func (k Kubus) Luas() float64 {
return math.Pow(k.Sisi, 2) * 6
}
Tambahkan file main_test.go dengan isi berikut:
package main
import "testing"
func TestKubus_TableDriven(t *testing.T) {
tests := []struct {
name string
sisi float64
expectedVolume float64
expectedLuas float64
}{
{"sisi 0", 0, 0, 0},
{"sisi 1", 1, 1, 6},
{"sisi 4", 4, 64, 96},
{"sisi 2.5", 2.5, 15.625, 37.5},
{"sisi 10", 10, 1000, 600},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
k := Kubus{tt.sisi}
if k.Volume() != tt.expectedVolume {
t.Errorf("Volume(%v) = %v, want %v", tt.sisi, k.Volume(), tt.expectedVolume)
}
if k.Luas() != tt.expectedLuas {
t.Errorf("Luas(%v) = %v, want %v", tt.sisi, k.Luas(), tt.expectedLuas)
}
})
}
}
Jalankan test:
go test -v -run TestKubus_TableDriven
Output ketika semua test case lolos:
=== RUN TestKubus_TableDriven
=== RUN TestKubus_TableDriven/sisi_0
=== RUN TestKubus_TableDriven/sisi_1
=== RUN TestKubus_TableDriven/sisi_4
=== RUN TestKubus_TableDriven/sisi_2.5
=== RUN TestKubus_TableDriven/sisi_10
--- PASS: TestKubus_TableDriven (0.00s)
--- PASS: TestKubus_TableDriven/sisi_0 (0.00s)
--- PASS: TestKubus_TableDriven/sisi_1 (0.00s)
--- PASS: TestKubus_TableDriven/sisi_4 (0.00s)
--- PASS: TestKubus_TableDriven/sisi_2.5 (0.00s)
--- PASS: TestKubus_TableDriven/sisi_10 (0.00s)
PASS
Setiap test case dijalankan sebagai sub-test terpisah lewat t.Run(). Fungsi ini menerima dua parameter: nama sub-test (string) dan fungsi test-nya (func(t *testing.T)). Keuntungannya, kalau satu test case gagal, yang lain tetap dijalankan dan hasilnya dilaporkan secara terpisah, sehingga mudah melacak bagian mana yang bermasalah.
Contoh output ketika satu test case gagal (misalnya expected volume untuk sisi 4 diubah ke nilai yang salah):
=== RUN TestKubus_TableDriven/sisi_4
main_test.go:24: Volume(4) = 64, want 999
--- FAIL: TestKubus_TableDriven/sisi_4 (0.00s)
Sub-test lain tetap dijalankan dan dilaporkan secara terpisah. Ini kelebihan utama dibanding test konvensional yang langsung berhenti saat ada error pertama.
A.59.3. Contoh Lain: Fungsi dengan Banyak Kondisi
Table-driven test sangat berguna untuk menguji fungsi yang punya banyak cabang kondisi. Mari praktikkan dengan membuat project baru.
File main.go:
package main
func KategorikanNilai(nilai int) string {
switch {
case nilai >= 90:
return "A"
case nilai >= 80:
return "B"
case nilai >= 70:
return "C"
case nilai >= 60:
return "D"
default:
return "E"
}
}
File main_test.go:
package main
import "testing"
func TestKategorikanNilai(t *testing.T) {
tests := []struct {
name string
nilai int
expected string
}{
{"nilai sempurna", 100, "A"},
{"nilai A minimum", 90, "A"},
{"nilai A batas", 89, "B"},
{"nilai B minimum", 80, "B"},
{"nilai B batas", 79, "C"},
{"nilai C minimum", 70, "C"},
{"nilai C batas", 69, "D"},
{"nilai D minimum", 60, "D"},
{"nilai D batas", 59, "E"},
{"nilai nol", 0, "E"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := KategorikanNilai(tt.nilai)
if result != tt.expected {
t.Errorf("KategorikanNilai(%d) = %s, want %s", tt.nilai, result, tt.expected)
}
})
}
}
Perhatikan betapa mudahnya menambah skenario baru: cukup tambahkan satu baris ke slice tests, tanpa perlu membuat fungsi test baru.
A.59.4. Menjalankan Sub-Test Tertentu
Flag -run pada go test mendukung pattern matching hingga ke level nama sub-test. Nama sub-test dibentuk dari NamaFungsiTest/nama_sub_test, dengan spasi otomatis diganti _. Pattern yang digunakan adalah regex.
# jalankan semua sub-test yang mengandung kata "batas"
go test -v -run TestKategorikanNilai/batas
# jalankan sub-test spesifik
go test -v -run TestKategorikanNilai/nilai_sempurna
A.59.5. Parallel Test
Sub-test bisa dijalankan secara paralel dengan memanggil t.Parallel() di awal fungsi sub-test. Ini berguna untuk mempercepat eksekusi test ketika antar test case tidak saling bergantung dan tidak mengakses data yang sama.
package main
import "testing"
func TestKategorikanNilai_Parallel(t *testing.T) {
tests := []struct {
name string
nilai int
expected string
}{
{"nilai A", 95, "A"},
{"nilai B", 85, "B"},
{"nilai C", 75, "C"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := KategorikanNilai(tt.nilai)
if result != tt.expected {
t.Errorf("KategorikanNilai(%d) = %s, want %s", tt.nilai, result, tt.expected)
}
})
}
}
Hindari penggunaan
t.Parallel()jika antar test case berbagi data yang bisa diubah (misalnya variabel global atau koneksi database yang sama), karena bisa menyebabkan race condition.Sejak Go 1.22, variabel loop sudah di-scope per iterasi, sehingga tidak perlu lagi menulis
tt := ttdi dalam loop untuk menghindari masalah pada parallel test.