Go Testing Dasar
Definisi
Di Go, testing adalah cara utama untuk memverifikasi perilaku kode tanpa harus menjalankan aplikasi secara manual setiap kali ada perubahan. Testing dipakai untuk menjaga kontrak perilaku, menangkap regresi, dan membuat refactor lebih aman.
Topik ini biasanya mencakup beberapa pola inti:
- table-driven tests: satu fungsi test menjalankan banyak skenario dari tabel data
testing.T: objek dasar untuk menandai test gagal, log, dan menandai helper- subtests: membagi satu test besar menjadi skenario-skenario kecil dengan
t.Run httptest: paket standar untuk menguji handler HTTP tanpa server sungguhan- mocks/fakes sederhana: pengganti dependency agar test fokus pada perilaku yang diuji
- benchmark: pengukuran dasar performa dengan
testing.B
Kalau project Go mulai punya banyak branch perilaku, test bukan lagi bonus. Test jadi spesifikasi hidup untuk logika yang penting.
Kapan testing dipakai
Testing paling berguna ketika kamu ingin jawaban cepat untuk pertanyaan seperti:
- apakah fungsi ini masih mengembalikan hasil yang benar setelah refactor?
- apakah handler HTTP masih membalas status code dan body yang benar?
- apakah error branch, timeout, atau fallback masih bekerja?
- apakah dependency eksternal bisa diganti supaya test tidak rapuh?
- apakah perubahan kecil memperlambat proses secara tidak sengaja?
Secara praktis, testing dipakai saat:
- logika mulai punya banyak cabang
- ada risiko regresi setelah perubahan
- dependency luar tidak stabil atau terlalu mahal dipakai langsung
- perilaku penting lebih mahal dicek manual berulang kali
- tim butuh spesifikasi yang bisa dibaca ulang oleh manusia dan mesin
Area yang paling terasa manfaatnya
- transformasi data
- validasi input
- parsing dan formatting
- service layer yang punya aturan bisnis
- HTTP handler dan middleware
- repository yang dibungkus dengan interface
- code path error, retry, dan timeout
Kalau bug yang sama muncul dua kali, biasanya sudah waktunya menulis test untuk bug itu. Test yang baik mengubah insiden menjadi spesifikasi.
Struktur test yang sehat
Struktur test yang sehat di Go biasanya punya pola yang sederhana dan konsisten.
Prinsip dasar
- satu test fokus pada satu perilaku utama
- nama test menjelaskan skenario, bukan implementasi internal
- input, aksi, dan verifikasi terlihat jelas
- setup test tidak terlalu banyak copy-paste
- dependency eksternal dikontrol atau diganti
- assertion gagal dengan pesan yang cukup jelas
Pola AAA
Banyak test sehat mengikuti pola Arrange - Act - Assert:
- Arrange: siapkan input, dependency, dan state awal
- Act: jalankan fungsi atau handler
- Assert: periksa hasil, error, side effect, atau output
Pola ini membantu test dibaca cepat. Kalau test mulai terlihat campur aduk, biasanya langkah-langkahnya belum dipisah dengan rapi.
Hal yang sebaiknya dijaga
- gunakan data test yang eksplisit, bukan angka atau string misterius
- jangan membuat test bergantung pada urutan test lain
- hindari state global yang bocor antar test
- beri helper kecil untuk setup yang berulang
- pakai
t.Helper()pada helper assertion agar stack trace lebih akurat
testing.T: fondasi test di Go
testing.T adalah objek yang dipakai hampir semua test unit di Go. Lewat *testing.T, kamu bisa menandai test gagal, mencatat log, menjalankan cleanup, dan menandai helper function.
Fungsi yang sering dipakai
t.Error/t.Errorf: menandai gagal tapi test tetap lanjutt.Fatal/t.Fatalf: menandai gagal dan menghentikan test saat itu jugat.Helper(): menandai helper supaya lokasi error lebih bergunat.Cleanup(fn): menjalankan cleanup setelah test selesait.Run(name, fn): menjalankan subtest
Kapan pakai Fatal dan kapan pakai Error
Pakai t.Fatal atau t.Fatalf kalau test tidak masuk akal untuk dilanjutkan setelah gagal. Contoh:
- setup gagal
- dependency penting tidak terbentuk
- response yang diuji tidak valid sejak awal
Pakai t.Error atau t.Errorf kalau kamu ingin mengumpulkan beberapa kegagalan dalam satu test. Untuk kebanyakan test perilaku, Fatalf sering lebih jelas karena kegagalan pertama sudah cukup untuk diagnosis.
Contoh penggunaan dasar
func TestNormalizeName(t *testing.T) {
got := NormalizeName(" andi ")
want := "Andi"
if got != want {
t.Fatalf("NormalizeName() = %q, want %q", got, want)
}
}Helper yang sehat
func requireNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}Helper() penting supaya failure line menunjuk ke caller, bukan ke dalam helper itu sendiri.
Table-driven tests
Table-driven test adalah pola default yang sangat umum di Go. Satu fungsi test menjalankan banyak skenario dari tabel data.
Kenapa pola ini dipakai
- test lebih ringkas
- skenario baru gampang ditambah
- perbandingan input-output terlihat jelas
- konsisten untuk fungsi yang punya banyak cabang
- mudah digabung dengan subtest
Bentuk dasar
func TestSlugify(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "trim spaces", in: " Halo Dunia ", want: "halo-dunia"},
{name: "empty", in: "", want: ""},
{name: "symbols", in: "Go & Testing!", want: "go-testing"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Slugify(tt.in)
if got != tt.want {
t.Fatalf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}Kenapa cocok digabung dengan subtests
Setiap baris tabel bisa jadi subtest sendiri. Itu membuat output test lebih mudah dipindai. Kalau satu skenario gagal, kamu langsung tahu skenario mana yang rusak.
Jebakan umum pada table-driven test
- loop variable ditangkap closure tanpa dibuat lokal ulang
- nama case tidak deskriptif
- assertion terlalu generik sehingga sulit debug
- semua skenario digabung tanpa subtest, lalu output gagal jadi sulit dibaca
- tabel berisi banyak setup yang menutupi maksud test
Catatan penting soal closure
Kalau memakai t.Run di loop, biasakan membuat salinan lokal dari variabel loop ketika perlu. Ini mencegah masalah capture yang membingungkan.
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// pakai tt di sini
})
}Subtests dengan t.Run
Subtests dipakai untuk memecah satu test besar menjadi beberapa skenario yang lebih spesifik. Ini berguna saat satu fungsi punya banyak branch atau saat kamu ingin mengelompokkan kasus berdasarkan kategori.
Kapan subtests dipakai
- satu test punya banyak case
- ingin menjalankan satu skenario tertentu lewat
go test -run - ingin set up dan tear down yang beda per skenario
- ingin hierarki test yang lebih jelas
Contoh subtest bertingkat
func TestParseRole(t *testing.T) {
tests := []struct {
name string
in string
want Role
}{
{name: "admin", in: "admin", want: RoleAdmin},
{name: "user", in: "user", want: RoleUser},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got := ParseRole(tt.in)
if got != tt.want {
t.Fatalf("ParseRole(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}Manfaat praktis subtests
- output lebih rapi
- case yang gagal lebih mudah diisolasi
- satu subset case bisa dijalankan sendiri
- setup khusus per case jadi mungkin tanpa mengotori case lain
Jebakan umum subtests
- terlalu banyak nesting sehingga output sulit dibaca
- nama subtest tidak konsisten
- subtest bergantung pada state yang diubah subtest lain
- cleanup tidak dipisah per subtest
Alur execution test
Mermaid membantu untuk melihat urutan eksekusi test, terutama saat memakai table-driven test dan subtests.
httptest untuk HTTP testing
Paket net/http/httptest dipakai untuk menguji handler HTTP tanpa perlu menjalankan server sungguhan.
Komponen yang sering dipakai
httptest.NewRequest: membuat request palsu untuk testhttptest.NewRecorder: merekam response handlerhttptest.NewServer: menjalankan server test sungguhan di port acakhttptest.ResponseRecorder: penampung hasil response
Kapan httptest dipakai
- menguji handler HTTP secara langsung
- memeriksa status code, header, dan body
- memastikan routing atau middleware menghasilkan response yang benar
- menguji flow request tanpa network call nyata
Contoh handler sederhana
func HelloHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello"))
}Test dengan httptest.NewRecorder
func TestHelloHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
rr := httptest.NewRecorder()
HelloHandler(rr, req)
res := rr.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "hello" {
t.Fatalf("body = %q, want %q", body, "hello")
}
}Kapan pakai NewServer
Pakai httptest.NewServer kalau kamu perlu menguji perilaku client atau middleware stack yang lebih realistis, misalnya saat ada redirect, cookie, transport, atau interaksi HTTP penuh.
func TestClientAgainstServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(HelloHandler))
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}Jebakan umum HTTP testing
- lupa memeriksa status code
- hanya mengecek body tanpa header atau method
- test terlalu tergantung pada text response yang sangat spesifik
- memakai server sungguhan padahal cukup
NewRecorder - tidak menutup body response
- tidak memisahkan test handler, middleware, dan client concern
Mocks dan fakes sederhana
Di Go, dependency sering dipisah lewat interface lalu diganti dengan fake sederhana saat test. Untuk banyak kasus, fake kecil lebih jujur dan lebih mudah dirawat daripada mock yang terlalu agresif.
Perbedaan singkat
- mock: objek kontrol yang biasanya memverifikasi interaksi tertentu
- fake: implementasi sederhana yang punya perilaku cukup untuk test
- stub: dependency yang hanya mengembalikan data tertentu
Contoh interface
type UserStore interface {
FindByID(id int) (User, error)
}Fake sederhana
type FakeUserStore struct {
Users map[int]User
}
func (f FakeUserStore) FindByID(id int) (User, error) {
user, ok := f.Users[id]
if !ok {
return User{}, ErrUserNotFound
}
return user, nil
}Contoh penggunaan di service
type UserService struct {
Store UserStore
}
func (s UserService) GetName(id int) (string, error) {
user, err := s.Store.FindByID(id)
if err != nil {
return "", err
}
return user.Name, nil
}Test dengan fake
func TestUserService_GetName(t *testing.T) {
svc := UserService{
Store: FakeUserStore{
Users: map[int]User{
1: {ID: 1, Name: "Andi"},
},
},
}
got, err := svc.GetName(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Andi" {
t.Fatalf("got %q, want %q", got, "Andi")
}
}Kapan fake lebih baik daripada mock
- perilaku dependency sederhana
- yang diuji adalah hasil, bukan urutan panggilan internal
- kamu ingin test tetap mudah dipahami 6 bulan lagi
Kapan mock lebih masuk akal
- interaksi spesifik memang bagian dari kontrak
- ada side effect yang harus diverifikasi
- kamu perlu memastikan call count atau urutan panggilan
Jebakan umum mock/fake
- mock terlalu detail sampai test mengunci implementasi internal
- fake terlalu pintar dan mulai meniru production logic penuh
- interface dibuat terlalu besar hanya untuk memudahkan test
- test memeriksa hal yang tidak penting bagi perilaku publik
Aturan praktisnya: kalau fake kecil sudah cukup, pakai fake. Kalau kamu benar-benar perlu memverifikasi interaksi, baru naik ke mock.
Benchmark dasar
Benchmark dipakai untuk mengukur performa secara kasar dan membandingkan perubahan. Di Go, benchmark dijalankan lewat fungsi BenchmarkXxx dengan *testing.B.
Kapan benchmark dipakai
- ingin tahu apakah refactor mempercepat atau memperlambat kode
- ingin membandingkan dua pendekatan implementasi
- ingin melihat alokasi memori atau throughput dasar
- ingin mengukur path yang sering dipanggil
Contoh benchmark
func BenchmarkSlugify(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Slugify("Go Testing dan HTTP")
}
}Menulis benchmark yang sehat
- jangan mengukur setup yang bukan bagian dari operasi utama
- siapkan data di luar loop kalau memang tidak ikut diukur
- gunakan
b.ResetTimer()setelah setup bila perlu - pakai
b.ReportAllocs()kalau ingin melihat alokasi
func BenchmarkParseJSON(b *testing.B) {
data := []byte(`{"name":"Andi","age":30}`)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var v map[string]any
_ = json.Unmarshal(data, &v)
}
}Jebakan umum benchmark
- benchmark terlalu kecil sehingga noise besar
- membandingkan hasil tanpa menjalankan cukup iterasi
- setup ikut terukur tanpa disengaja
- membaca angka benchmark sebagai kebenaran mutlak, padahal konteks mesin dan load juga berpengaruh
Benchmark bagus untuk diagnosis performa, bukan untuk menggantikan profiling.
Alur keputusan: pakai test, subtest, atau benchmark?
Struktur paket test yang sehat
Struktur yang umum dan sehat di Go:
package/
├── service.go
├── handler.go
├── service_test.go
├── handler_test.go
└── testdata/
└── sample.jsonPrinsipnya
- test dekat dengan kode yang diuji
- file test dipisah per unit logika yang jelas
- fixture disimpan di
testdatakalau datanya besar atau dipakai ulang - helper test bisa diletakkan di file khusus bila mulai banyak
Kapan memakai testdata
Pakai testdata kalau:
- input test cukup besar
- kamu perlu file fixture nyata
- isi fixture ingin mudah dibaca ulang
- lebih nyaman menyimpan contoh payload daripada inline string panjang
Kesalahan umum pada test yang rapuh
Test rapuh biasanya lulus hari ini, lalu gagal karena perubahan kecil yang sebenarnya tidak memengaruhi perilaku penting.
Ciri test rapuh
- terlalu bergantung pada detail implementasi internal
- terlalu banyak mocking untuk hal yang tidak penting
- bergantung pada waktu, urutan, atau random tanpa kontrol
- assertion terlalu spesifik pada formatting yang tidak penting
- share state antar test
- hasil test tergantung environment lokal
Contoh jebakan yang sering muncul
- membandingkan error string mentah padahal yang penting jenis error-nya
- menunggu
time.Sleeptanpa alasan yang jelas - membaca output log sebagai satu-satunya bukti perilaku
- test jaringan yang bergantung internet nyata
- memakai global variable yang dimodifikasi lintas test
Cara mengurangi kerapuhan
- fokus pada kontrak perilaku, bukan detail internal
- kontrol clock dan random bila perlu
- gunakan helper dan fake sederhana
- buat assertion yang relevan dengan user-visible behavior atau contract internal yang benar-benar penting
- jalankan test dalam kondisi paralel hanya kalau state memang aman
Catatan implementasi
Beberapa kebiasaan kecil sangat membantu kualitas test:
- beri nama test yang menjelaskan kondisi dan hasil
- simpan setup yang berulang dalam helper
- pakai
t.Cleanupuntuk resource yang perlu ditutup - gunakan
httptestuntuk HTTP layer, bukan server sungguhan kalau tidak perlu - pilih fake sederhana dulu sebelum mock kompleks
- gunakan benchmark ketika ada pertanyaan performa yang konkret
Referensi ringkas
- package
testing - package
net/http/httptest - package
net/http - subtest dengan
t.Run - benchmark dengan
testing.B
Kalau perlu, halaman berikutnya bisa memperluas topik ini ke test parallel, coverage, race detector, dan integration test untuk service yang lebih besar.