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 Test dan 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:

MethodPerilaku
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:

FungsiKegunaan
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.

Referensi

  1. 1Package testing — Go Standard Library, pkg.go.dev
  2. 2stretchr/testify — A toolkit with common assertions and mocks, GitHub
  3. 3Testing in Go with Testify — Better Stack Community
  4. 4Benchmarking in Go: A Comprehensive Handbook — Better Stack Community