Tes tabel & test double
Menambah kasus uji seharusnya berarti menambah satu baris, bukan menyalin satu fungsi. Tes tabel memberi itu, dan interface implisit Go membuat test double jadi struct kecil belasan baris.
Intisari
- Satu tes, banyak kasus dalam satu slice atau map โ menambah kasus berarti menambah satu baris.
- Beri nama tiap kasus; nama itu muncul di keluaran kegagalan dan bisa dipakai
-run. - Fake (implementasi sederhana) hampir selalu lebih baik daripada mock (harapan panggilan).
- Karena interface Go implisit, test double cuma struct dengan method yang dibutuhkan โ tanpa pustaka.
- Jangan menguji interaksi ("method X dipanggil dua kali") kecuali interaksinya memang bagian dari kontrak.
Bentuk tes tabel
func TestHitungOngkir(t *testing.T) {
t.Parallel()
kasus := []struct {
nama string
berat int
zona string
mau int64
mauErr error
}{
{"dalam kota ringan", 500, "kota", 9000, nil},
{"dalam kota berat", 5000, "kota", 25000, nil},
{"luar pulau", 1000, "luar-pulau", 45000, nil},
{"berat nol", 0, "kota", 0, ErrBeratTidakValid},
{"zona tak dikenal", 1000, "bulan", 0, ErrZonaTidakDikenal},
}
for _, k := range kasus {
t.Run(k.nama, func(t *testing.T) {
t.Parallel()
got, err := HitungOngkir(k.berat, k.zona)
if !errors.Is(err, k.mauErr) {
t.Fatalf("HitungOngkir(%d, %q) err = %v, mau %v",
k.berat, k.zona, err, k.mauErr)
}
if got != k.mau {
t.Errorf("HitungOngkir(%d, %q) = %d, mau %d",
k.berat, k.zona, got, k.mau)
}
})
}
}
--- FAIL: TestHitungOngkir/luar_pulau (0.00s)
ongkir_test.go:31: HitungOngkir(1000, "luar-pulau") = 40000, mau 45000
Yang membuat pola ini menang: kasus baru adalah satu baris data, bukan satu fungsi baru. Itu menurunkan biaya menambah kasus tepi sampai hampir nol โ dan kasus tepi justru yang paling sering menyimpan bug.
Kapan tes tabel bukan pilihan
// โ Kalau setiap kasus butuh persiapan berbeda, tabelnya penuh
// field kondisional dan lebih sulit dibaca daripada tes terpisah.
kasus := []struct {
nama string
siapkanDB func(*testing.T, *sql.DB)
tiruanGagal bool
lewatiJika string
...
}
Kalau tabelmu punya lebih dari sekitar enam kolom, atau berisi banyak func, tulis beberapa
tes terpisah dengan nama yang jelas. Tes tabel untuk kasus yang seragam.
Test double tanpa pustaka
// Interface yang dideklarasikan domain (Fase 1)
type Penyimpan interface {
Ambil(ctx context.Context, id int64) (Produk, error)
Simpan(ctx context.Context, p Produk) error
}
// FAKE: implementasi sederhana yang benar-benar bekerja.
type penyimpanMemori struct {
mu sync.Mutex
data map[int64]Produk
errAmbil error // untuk menguji jalur kegagalan
}
func penyimpanBaru(isi ...Produk) *penyimpanMemori {
m := &penyimpanMemori{data: map[int64]Produk{}}
for _, p := range isi {
m.data[p.ID] = p
}
return m
}
func (m *penyimpanMemori) Ambil(_ context.Context, id int64) (Produk, error) {
if m.errAmbil != nil {
return Produk{}, m.errAmbil
}
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.data[id]
if !ok {
return Produk{}, ErrTidakDitemukan
}
return p, nil
}
func (m *penyimpanMemori) Simpan(_ context.Context, p Produk) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[p.ID] = p
return nil
}
func TestBeli(t *testing.T) {
simpan := penyimpanBaru(Produk{ID: 1, Stok: 10})
svc := NewLayanan(simpan, cacheKosong{}, slog.Default())
if err := svc.Beli(t.Context(), 1, 3); err != nil {
t.Fatalf("Beli() error = %v", err)
}
p, _ := simpan.Ambil(t.Context(), 1)
if p.Stok != 7 {
t.Errorf("stok setelah beli = %d, mau 7", p.Stok)
}
}
func TestBeliSaatDBGagal(t *testing.T) {
simpan := penyimpanBaru()
simpan.errAmbil = errors.New("koneksi putus")
svc := NewLayanan(simpan, cacheKosong{}, slog.Default())
if err := svc.Beli(t.Context(), 1, 3); err == nil {
t.Fatal("mau error, dapat nil")
}
}
Perhatikan bahwa tidak ada satu pun pustaka mocking di atas. Karena interface Go implisit, struct mana pun yang punya method yang tepat sudah memenuhinya โ dan fake berbasis map hampir selalu lebih mudah dibaca serta lebih tahan terhadap perubahan daripada mock yang menyatakan urutan panggilan.
Fake versus mock
| Fake | Mock | |
|---|---|---|
| Isi | Implementasi sederhana yang bekerja | Harapan: "method X dipanggil dengan Y" |
| Menguji | Hasil | Interaksi |
| Saat implementasi diubah | Tetap lulus kalau perilakunya sama | Sering ikut gagal |
| Keterbacaan | Kode Go biasa | DSL pustaka |
| Cocok untuk | Hampir semua | Efek samping yang memang jadi kontrak (email terkirim, pesan diantre) |
// Kalau yang PENTING memang interaksinya, catat saja โ tetap tanpa pustaka.
type pengirimPalsu struct {
terkirim []Email
}
func (p *pengirimPalsu) Kirim(_ context.Context, e Email) error {
p.terkirim = append(p.terkirim, e)
return nil
}
// lalu di tes:
if len(pengirim.terkirim) != 1 {
t.Fatalf("email terkirim = %d, mau 1", len(pengirim.terkirim))
}
if pengirim.terkirim[0].Ke != "[email protected]" {
t.Errorf("penerima = %q", pengirim.terkirim[0].Ke)
}
Menguji waktu
// Waktu disuntikkan sebagai dependensi (Fase 5)
svc := NewLayanan(simpan, katalog,
DenganJam(func() time.Time {
return time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
}))
// Sekarang "kupon kedaluwarsa setelah 30 hari" bisa diuji
// tanpa satu pun time.Sleep.
Setiap time.Sleep di dalam tes adalah utang. Ia memperlambat suite, dan cepat atau
lambat ia jadi flaky di CI yang lebih lambat daripada laptopmu. Untuk kode berbasis waktu:
suntikkan jam. Untuk concurrency berbasis waktu: testing/synctest (materi berikutnya).
Latihan: ubah salah satu tesmu jadi tes tabel dengan minimal lima kasus, termasuk dua kasus
error. Lalu tulis fake berbasis map untuk repository-mu dan uji satu alur service tanpa menyentuh
database. Terakhir, tambahkan field errAmbil pada fake itu dan tulis tes untuk jalur
kegagalannya โ jalur itu biasanya yang paling jarang diuji dan paling sering rusak.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.