BAB 52: Unit Test — Kode yang Bisa Dipercaya
Pelajari cara menulis unit test dan benchmark di Go menggunakan package testing dan testify: table-driven tests, subtests, benchmark, dan pola pengujian yang idiomatis.
Di bab-bab sebelumnya, KontenKu sudah tumbuh menjadi sistem yang cukup kompleks — menyimpan artikel ke MySQL, metadata fleksibel ke MongoDB, menyajikan data lewat HTTP, bahkan berkomunikasi dengan layanan eksternal. Setiap fitur baru yang ditambahkan membawa risiko: apakah kode lama masih bekerja seperti yang diharapkan?
Tanpa mekanisme pengujian, satu-satunya cara memastikan sistem berjalan dengan benar adalah dengan menjalankannya secara manual dan memeriksa hasilnya satu per satu. Cara ini tidak skalabel — semakin besar sistem, semakin besar kemungkinan ada bagian yang terlewat. Di sinilah unit test berperan: kode yang secara otomatis memverifikasi bahwa kode lain bekerja sesuai harapan.
Go menyertakan package testing dalam standard library, sehingga tidak perlu dependensi eksternal untuk menulis test dasar. Nanti kita juga akan melihat testify, library pihak ketiga yang membuat assertions jauh lebih ekspresif.
Anatomi Test di Go
Go punya konvensi ketat untuk file test:
- Nama file harus diakhiri dengan
_test.go - File test ditempatkan di package yang sama dengan kode yang diuji
- Nama fungsi test harus diawali dengan
Testdan menerima parameter*testing.T
Mari kita buat modul sederhana untuk diuji — fungsi-fungsi perhitungan statistik artikel di KontenKu:
// statistik.go
package kontenku
// HitungRataRata menghitung rata-rata dari slice int64.
// Mengembalikan 0 jika slice kosong.
func HitungRataRata(nilai []int64) float64 {
if len(nilai) == 0 {
return 0
}
var total int64
for _, n := range nilai {
total += n
}
return float64(total) / float64(len(nilai))
}
// NilaiTertinggi mengembalikan nilai terbesar dari slice int64.
// Mengembalikan 0 jika slice kosong.
func NilaiTertinggi(nilai []int64) int64 {
if len(nilai) == 0 {
return 0
}
tertinggi := nilai[0]
for _, n := range nilai[1:] {
if n > tertinggi {
tertinggi = n
}
}
return tertinggi
}
// FilterDiAtasAmbang mengembalikan slice baru berisi nilai yang >= ambang.
func FilterDiAtasAmbang(nilai []int64, ambang int64) []int64 {
var hasil []int64
for _, n := range nilai {
if n >= ambang {
hasil = append(hasil, n)
}
}
return hasil
}
Sekarang buat file test-nya:
// statistik_test.go
package kontenku
import "testing"
func TestHitungRataRata(t *testing.T) {
tampilan := HitungRataRata([]int64{100, 200, 300})
expected := 200.0
if tampilan != expected {
t.Errorf("HitungRataRata: dapat %.2f, harusnya %.2f", tampilan, expected)
}
}
func TestHitungRataRataSliceKosong(t *testing.T) {
hasil := HitungRataRata([]int64{})
if hasil != 0 {
t.Errorf("slice kosong harus mengembalikan 0, bukan %.2f", hasil)
}
}
Jalankan test dengan perintah:
go test ./...
Flag -v menampilkan detail setiap fungsi test yang dijalankan:
go test -v ./...
Output sukses akan terlihat seperti ini:
--- PASS: TestHitungRataRata (0.00s)
--- PASS: TestHitungRataRataSliceKosong (0.00s)
PASS
ok kontenku 0.002s
Jika ada test yang gagal, Go akan menampilkan pesan dari t.Errorf() beserta nama fungsi test yang gagal.
Method Testing
Package testing menyediakan banyak method di *testing.T. Perbedaan utama yang perlu dipahami adalah antara method yang melanjutkan eksekusi setelah gagal versus yang menghentikannya:
| Method | Perilaku |
|---|---|
Log(args...) | Cetak log (hanya tampil saat -v atau test gagal) |
Logf(format, args...) | Cetak log dengan format |
Error(args...) | Log + tandai fail, test tetap dilanjutkan |
Errorf(format, args...) | Logf + tandai fail, test tetap dilanjutkan |
Fatal(args...) | Log + tandai fail + hentikan fungsi test |
Fatalf(format, args...) | Logf + tandai fail + hentikan fungsi test |
Fail() | Tandai fail, test tetap dilanjutkan |
FailNow() | Tandai fail + hentikan fungsi test |
Skip(args...) | Log + tandai skip + hentikan fungsi test |
Skipf(format, args...) | Logf + tandai skip + hentikan fungsi test |
Parallel() | Jalankan test ini secara paralel dengan test lain |
Kapan pakai Fatal vs Error? Gunakan Fatal saat kegagalan membuat sisa test tidak bermakna — misalnya koneksi database gagal, tidak ada gunanya melanjutkan. Gunakan Error saat ingin mengumpulkan semua kegagalan sebelum berhenti, supaya satu kali jalankan go test bisa menampilkan semua masalah sekaligus.
Table-Driven Tests
Pola yang paling idiomatis di Go untuk menguji banyak skenario adalah table-driven tests. Daripada menulis satu fungsi test per kasus, semua kasus dikumpulkan dalam satu slice struct, lalu diiterasi.
// statistik_test.go (dikembangkan dari versi sebelumnya)
package kontenku
import "testing"
func TestHitungRataRata(t *testing.T) {
kasusUji := []struct {
nama string
masukan []int64
harapan float64
}{
{
nama: "nilai normal",
masukan: []int64{100, 200, 300, 400},
harapan: 250.0,
},
{
nama: "satu elemen",
masukan: []int64{75},
harapan: 75.0,
},
{
nama: "slice kosong",
masukan: []int64{},
harapan: 0.0,
},
{
nama: "semua nilai sama",
masukan: []int64{50, 50, 50},
harapan: 50.0,
},
}
for _, k := range kasusUji {
t.Run(k.nama, func(t *testing.T) {
hasil := HitungRataRata(k.masukan)
if hasil != k.harapan {
t.Errorf("dapat %.2f, harusnya %.2f", hasil, k.harapan)
}
})
}
}
t.Run() membuat subtest — setiap kasus punya nama dan bisa dijalankan secara independen. Output dengan -v:
--- PASS: TestHitungRataRata (0.00s)
--- PASS: TestHitungRataRata/nilai_normal (0.00s)
--- PASS: TestHitungRataRata/satu_elemen (0.00s)
--- PASS: TestHitungRataRata/slice_kosong (0.00s)
--- PASS: TestHitungRataRata/semua_nilai_sama (0.00s)
Untuk menjalankan hanya subtest tertentu, gunakan flag -run dengan pola regex:
# Hanya jalankan subtest "slice kosong"
go test -v -run "TestHitungRataRata/slice_kosong"
Pola ini sangat berguna saat sedang debug satu kasus spesifik tanpa harus menjalankan seluruh test suite.
Menguji Fungsi yang Lebih Kompleks
Mari kita tambahkan test untuk FilterDiAtasAmbang dan NilaiTertinggi, sekalian mendemonstrasikan cara membandingkan slice di test:
// statistik_test.go (tambahkan fungsi test ini)
package kontenku
import (
"reflect"
"testing"
)
func TestNilaiTertinggi(t *testing.T) {
kasusUji := []struct {
nama string
masukan []int64
harapan int64
}{
{"banyak nilai", []int64{30, 120, 75, 200, 55}, 200},
{"satu nilai", []int64{42}, 42},
{"nilai negatif", []int64{-5, -1, -20}, -1},
{"slice kosong", []int64{}, 0},
}
for _, k := range kasusUji {
t.Run(k.nama, func(t *testing.T) {
hasil := NilaiTertinggi(k.masukan)
if hasil != k.harapan {
t.Errorf("dapat %d, harusnya %d", hasil, k.harapan)
}
})
}
}
func TestFilterDiAtasAmbang(t *testing.T) {
kasusUji := []struct {
nama string
masukan []int64
ambang int64
harapan []int64
}{
{
nama: "filter normal",
masukan: []int64{10, 50, 100, 200, 30},
ambang: 50,
harapan: []int64{50, 100, 200},
},
{
nama: "semua lolos filter",
masukan: []int64{100, 200, 300},
ambang: 50,
harapan: []int64{100, 200, 300},
},
{
nama: "tidak ada yang lolos",
masukan: []int64{10, 20, 30},
ambang: 100,
harapan: nil,
},
}
for _, k := range kasusUji {
t.Run(k.nama, func(t *testing.T) {
hasil := FilterDiAtasAmbang(k.masukan, k.ambang)
if !reflect.DeepEqual(hasil, k.harapan) {
t.Errorf("dapat %v, harusnya %v", hasil, k.harapan)
}
})
}
}
reflect.DeepEqual() dari package reflect — yang sudah kita bahas di Bab 21 — adalah cara idiomatis untuk membandingkan slice atau map di test standar Go. Ini membandingkan isi secara rekursif, bukan hanya alamat memori.
Perhatikan bahwa test untuk “tidak ada yang lolos” membandingkan hasil dengan nil, bukan []int64{}. Fungsi FilterDiAtasAmbang menggunakan append ke slice yang dimulai dari nil, sehingga jika tidak ada elemen yang ditambahkan, nilai kembaliannya adalah nil — bukan slice kosong berukuran 0. reflect.DeepEqual(nil, []int64{}) mengembalikan false.
Setup dan Teardown dengan TestMain
Kadang beberapa test dalam satu package membutuhkan setup yang sama — misalnya membuka koneksi database test, menyiapkan fixture data, atau membuat direktori sementara. Go menyediakan TestMain untuk keperluan ini:
// statistik_test.go (tambahkan di level package, bukan di dalam fungsi lain)
package kontenku
import (
"fmt"
"os"
"testing"
)
var dataArtikelTest []int64
func TestMain(m *testing.M) {
// setup: siapkan data fixture
dataArtikelTest = []int64{150, 320, 75, 480, 210, 95, 630}
fmt.Println("setup test selesai, memulai pengujian...")
// m.Run() menjalankan semua test dalam package ini
kodeKeluar := m.Run()
// teardown: bersihkan resource jika diperlukan
fmt.Println("pengujian selesai, membersihkan resource...")
os.Exit(kodeKeluar)
}
func TestStatistikArtikelKontenKu(t *testing.T) {
t.Run("rata-rata views", func(t *testing.T) {
rata := HitungRataRata(dataArtikelTest)
// rata harus lebih dari 0 untuk data yang valid
if rata <= 0 {
t.Errorf("rata-rata tidak valid: %.2f", rata)
}
})
t.Run("artikel paling populer", func(t *testing.T) {
tertinggi := NilaiTertinggi(dataArtikelTest)
if tertinggi != 630 {
t.Errorf("artikel terpopuler harus 630 views, bukan %d", tertinggi)
}
})
}
TestMain harus memanggil m.Run() untuk menjalankan test, dan os.Exit() dengan nilai kembalian m.Run() supaya exit code mencerminkan keberhasilan atau kegagalan test.
Benchmark
Selain mengukur kebenaran, package testing juga bisa mengukur performa. Fungsi benchmark diawali Benchmark, menerima *testing.B, dan berisi loop for i := 0; i < b.N; i++:
// statistik_test.go (tambahkan benchmark ini)
package kontenku
import "testing"
func BenchmarkHitungRataRata(b *testing.B) {
data := make([]int64, 1000)
for i := range data {
data[i] = int64(i * 100)
}
b.ResetTimer() // reset timer setelah setup, supaya waktu setup tidak ikut dihitung
for i := 0; i < b.N; i++ {
HitungRataRata(data)
}
}
func BenchmarkFilterDiAtasAmbang(b *testing.B) {
data := make([]int64, 1000)
for i := range data {
data[i] = int64(i)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
FilterDiAtasAmbang(data, 500)
}
}
Jalankan benchmark dengan flag -bench:
# Jalankan semua benchmark
go test -bench=. ./...
# Jalankan benchmark tertentu
go test -bench=BenchmarkHitungRataRata ./...
# Tampilkan alokasi memori per operasi
go test -bench=. -benchmem ./...
Contoh output:
BenchmarkHitungRataRata-8 5000000 245 ns/op 0 B/op 0 allocs/op
BenchmarkFilterDiAtasAmbang-8 1000000 1123 ns/op 4096 B/op 1 allocs/op
Kolom -8 menunjukkan jumlah CPU yang digunakan. 245 ns/op artinya rata-rata 245 nanosecond per pemanggilan fungsi. 0 allocs/op artinya tidak ada alokasi heap — fungsi murni beroperasi di stack, yang biasanya lebih cepat. FilterDiAtasAmbang melakukan satu alokasi (append membuat slice baru) sebesar 4096 byte.
b.ResetTimer() penting ketika setup benchmark membutuhkan waktu signifikan — tanpanya, waktu setup akan ikut dihitung dalam pengukuran.
Benchmark paling berguna ketika membandingkan dua implementasi berbeda. Tulis benchmark untuk keduanya, jalankan dengan flag -benchmem, dan bandingkan hasilnya. Go juga menyediakan benchstat (tool terpisah) untuk analisis statistik yang lebih ketat dari beberapa run benchmark.
Menguji dengan Testify
Standard library sudah cukup untuk banyak kasus, tapi testify membuat assertion jauh lebih ekspresif. Install terlebih dahulu:
go get github.com/stretchr/testify
Testify punya dua package utama: assert dan require. Perbedaannya satu: assert melanjutkan eksekusi test setelah kegagalan (menggunakan t.Errorf di balik layar), sedangkan require menghentikannya (menggunakan t.Fatalf).
// statistik_test.go (versi testify)
package kontenku
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHitungRataRataDenganTestify(t *testing.T) {
kasusUji := []struct {
nama string
masukan []int64
harapan float64
}{
{"nilai normal", []int64{100, 200, 300}, 200.0},
{"satu nilai", []int64{500}, 500.0},
{"slice kosong", []int64{}, 0.0},
}
for _, k := range kasusUji {
t.Run(k.nama, func(t *testing.T) {
hasil := HitungRataRata(k.masukan)
assert.Equal(t, k.harapan, hasil, "rata-rata tidak sesuai untuk kasus: %s", k.nama)
})
}
}
func TestFilterDenganTestify(t *testing.T) {
data := []int64{10, 50, 100, 200, 30}
hasil := FilterDiAtasAmbang(data, 50)
require.NotNil(t, hasil, "hasil filter tidak boleh nil")
require.Len(t, hasil, 3, "harus ada 3 elemen yang lolos filter")
assert.Contains(t, hasil, int64(50))
assert.Contains(t, hasil, int64(100))
assert.Contains(t, hasil, int64(200))
assert.NotContains(t, hasil, int64(10))
assert.NotContains(t, hasil, int64(30))
}
Perhatikan penggunaan require untuk pengecekan fundamental (NotNil, Len) — jika hasil nil atau panjangnya salah, tidak ada gunanya melanjutkan ke assertion berikutnya. Setelah yang fundamental aman, assert digunakan untuk memeriksa detail isi.
Beberapa assertion testify yang paling sering digunakan:
| Fungsi | Kegunaan |
|---|---|
assert.Equal(t, expected, actual) | Membandingkan nilai dengan == (atau DeepEqual untuk slice/map) |
assert.NotEqual(t, a, b) | Memastikan dua nilai tidak sama |
assert.Nil(t, val) | Memastikan nilai adalah nil |
assert.NotNil(t, val) | Memastikan nilai bukan nil |
assert.True(t, kondisi) | Memastikan kondisi true |
assert.False(t, kondisi) | Memastikan kondisi false |
assert.Len(t, obj, n) | Memeriksa panjang slice/map/channel |
assert.Contains(t, haystack, needle) | Memeriksa containment di string atau slice |
assert.Error(t, err) | Memastikan error bukan nil |
assert.NoError(t, err) | Memastikan error adalah nil |
assert.ErrorIs(t, err, target) | Memeriksa tipe error dengan errors.Is |
assert.Equal dari testify menangani slice dan struct secara otomatis — tidak perlu reflect.DeepEqual secara manual. Pesan error yang dihasilkan pun lebih informatif: testify menampilkan diff antara nilai yang diharapkan dan yang diterima.
Menjalankan Test Secara Selektif
go test mendukung beberapa flag yang berguna dalam praktik sehari-hari:
# Jalankan semua test di semua package
go test ./...
# Jalankan test tertentu berdasarkan nama (regex)
go test -run TestHitungRataRata ./...
# Jalankan test paralel dengan 4 goroutine
go test -parallel 4 ./...
# Hentikan saat test pertama gagal
go test -failfast ./...
# Tampilkan coverage report
go test -cover ./...
# Generate coverage report dalam format HTML
go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out
Flag -run menerima ekspresi reguler. go test -run "TestHitung" akan menjalankan semua fungsi test yang namanya mengandung TestHitung. Untuk subtest, gunakan slash: go test -run "TestHitungRataRata/slice_kosong".
Pastikan test tidak bergantung pada urutan eksekusi. Setiap fungsi test harus bisa berdiri sendiri dan menghasilkan hasil yang sama meskipun urutan eksekusinya berbeda. Go tidak menjamin urutan eksekusi antar fungsi test dalam satu package.
Latihan
Latihan 1 — Test untuk edge case:
Buat fungsi HitungMedian(nilai []int64) float64 yang menghitung nilai tengah dari slice yang sudah diurutkan. Tulis table-driven test untuk kasus: slice dengan jumlah elemen ganjil, jumlah elemen genap, satu elemen, dan slice kosong.
Latihan 2 — Benchmark perbandingan:
Buat dua implementasi CariNilaiTertinggi — satu menggunakan loop manual (seperti NilaiTertinggi di atas) dan satu menggunakan sort.Slice. Tulis benchmark untuk keduanya dengan data 1000 elemen dan bandingkan hasilnya dengan go test -bench=. -benchmem.
Latihan 3 — Test dengan testify suite:
Testify punya package suite yang memungkinkan grouping test dalam satu struct dengan method SetupTest() yang dipanggil sebelum setiap test. Eksplorasi dokumentasi testify/suite dan buat test suite untuk menguji semua fungsi statistik KontenKu.
Unit test bukan hanya tentang membuktikan bahwa kode bekerja sekarang — ini tentang memiliki jaring pengaman yang memberi kepercayaan diri saat mengubah kode nanti. Setiap refactoring, setiap optimasi, setiap perubahan dependensi akan lebih aman ketika ada test yang memverifikasi perilaku yang diharapkan.
KontenKu kini punya fondasi pengujian. Tapi ada satu bentuk pengujian yang lebih dekat ke pengguna nyata: menguji bagaimana seluruh komponen sistem bekerja bersama — bukan unit per unit, tapi sebagai satu kesatuan. Itu adalah integration test, dan alat yang digunakan di Go untuk keperluan tersebut bisa jadi sangat berbeda dari apa yang sudah kita tulis di bab ini.