Unit Test #
Go mempunyai dukungan testing built-in yang luar biasa. Tidak perlu framework eksternal seperti JUnit atau pytest — package testing di standard library sudah cukup untuk semua kebutuhan: unit test, benchmark, example test, dan fuzz test. Tooling-nya terintegrasi langsung dengan go command, dan konvensinya sangat konsisten di seluruh ekosistem Go sehingga setiap developer Go bisa langsung memahami test yang ditulis orang lain.
Konvensi Dasar #
Aturan file test:
- Nama file harus diakhiri dengan _test.go
- File _test.go TIDAK ikut dikompilasi ke binary produksi
- Bisa berada di package yang sama (white-box) atau package_test (black-box)
Aturan fungsi test:
- Diawali dengan Test (bukan test atau TEST)
- Menerima satu parameter: t *testing.T
- Tidak mengembalikan nilai apapun
Contoh:
func TestNamaFungsi(t *testing.T) { ... } ✓
func testNamaFungsi(t *testing.T) { ... } ✗ tidak dijalankan go test
func TestNamaFungsi() { ... } ✗ compile error
White-box vs Black-box Testing #
// calculator.go
package calculator
func Add(a, b int) int { return a + b }
func add(a, b int) int { return a + b } // unexported
// White-box: package sama, bisa akses unexported
// calculator_test.go
package calculator
func TestAdd(t *testing.T) {
_ = add(1, 2) // bisa akses fungsi unexported
}
// Black-box: package berbeda, hanya akses exported
// calculator_test.go
package calculator_test
import "myapp/calculator"
func TestAdd(t *testing.T) {
result := calculator.Add(1, 2) // hanya exported
_ = result
}
testing.T — Metode Utama
#
func TestContoh(t *testing.T) {
// Log — cetak informasi tambahan (hanya tampil jika test gagal atau -v)
t.Log("Memulai test...")
t.Logf("Nilai: %d", 42)
// Error — tandai test gagal tapi LANJUTKAN eksekusi
t.Error("sesuatu salah")
t.Errorf("nilai %d tidak sesuai ekspektasi %d", got, want)
// Fatal — tandai test gagal dan HENTIKAN test ini
t.Fatal("error kritis, tidak bisa lanjut")
t.Fatalf("tidak bisa buka file: %v", err)
// Skip — lewati test ini (misalnya di environment tertentu)
if runtime.GOOS == "windows" {
t.Skip("test ini tidak didukung di Windows")
}
// Fail / FailNow — mirip Error/Fatal tapi tanpa pesan
t.Fail() // tandai gagal, lanjut
t.FailNow() // tandai gagal, stop
}
Table-Driven Tests — Pola Idiomatik Go #
Ini adalah pola paling penting dalam testing Go. Alih-alih satu fungsi test per skenario, semua skenario dikelompokkan dalam satu tabel (slice of struct):
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{name: "pembagian normal", a: 10, b: 2, want: 5, wantErr: false},
{name: "pembagian desimal", a: 7, b: 2, want: 3.5, wantErr: false},
{name: "pembagi nol", a: 10, b: 0, want: 0, wantErr: true},
{name: "pembagian negatif", a: -6, b: 3, want: -2, wantErr: false},
{name: "keduanya nol", a: 0, b: 0, want: 0, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
// Cek error
if (err != nil) != tt.wantErr {
t.Errorf("Divide() error = %v, wantErr = %v", err, tt.wantErr)
return
}
// Cek hasil (hanya jika tidak error)
if !tt.wantErr && got != tt.want {
t.Errorf("Divide() = %v, want %v", got, tt.want)
}
})
}
}
Menjalankan test spesifik dengan -run:
go test ./... # semua test
go test -run TestDivide # semua subtest TestDivide
go test -run TestDivide/pembagi_nol # subtest spesifik (spasi → _)
go test -v ./... # verbose — tampilkan semua output
t.Parallel() — Test Concurrent
#
Test yang tidak bergantung satu sama lain bisa dijalankan secara parallel untuk mempercepat test suite:
func TestLambat(t *testing.T) {
tests := []struct {
name string
input int
}{
{"kasus A", 1},
{"kasus B", 2},
{"kasus C", 3},
}
for _, tt := range tests {
tt := tt // PENTING: capture loop variable sebelum Go 1.22
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // subtest ini berjalan concurrent dengan subtest lain
time.Sleep(100 * time.Millisecond) // simulasi operasi lambat
// ... test logic
})
}
}
Capture loop variable sebelumt.Parallel(). Sebelum Go 1.22, variabel loop di-share antar iterasi — tanpatt := tt, semua goroutine akan menggunakan nilaittyang sama (nilai terakhir). Sejak Go 1.22, ini sudah diperbaiki di level bahasa, tapi kebiasaan defensif ini masih baik untuk dipegang.
t.Helper() — Fungsi Helper yang Benar
#
Ketika membuat fungsi helper untuk test, panggil t.Helper() agar baris yang dilaporkan saat gagal adalah baris pemanggil, bukan baris di dalam helper:
// Tanpa t.Helper() — error menunjuk ke baris di dalam assertEqual
func assertEqual(t *testing.T, got, want int) {
if got != want {
t.Errorf("got %d, want %d", got, want) // ← baris ini yang dilaporkan
}
}
// Dengan t.Helper() — error menunjuk ke baris pemanggil assertEqual
func assertEqual(t *testing.T, got, want int) {
t.Helper() // ← tambahkan ini!
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
// Penggunaan
func TestSesuatu(t *testing.T) {
result := compute(5)
assertEqual(t, result, 10) // ← baris ini yang dilaporkan jika gagal ✓
}
TestMain — Setup dan Teardown Global
#
TestMain dijalankan sebelum dan sesudah semua test dalam package — berguna untuk koneksi database, server test, dll:
func TestMain(m *testing.M) {
// SETUP — jalankan sebelum semua test
db, err := setupTestDatabase()
if err != nil {
log.Fatal("Gagal setup database test:", err)
}
testDB = db // simpan ke variabel package-level
// Jalankan semua test
code := m.Run()
// TEARDOWN — jalankan setelah semua test
testDB.Close()
cleanupTestData()
os.Exit(code) // WAJIB: gunakan exit code dari m.Run()
}
Benchmark #
Benchmark mengukur performa fungsi. Fungsi benchmark diawali dengan Benchmark dan menerima *testing.B:
func BenchmarkAdd(b *testing.B) {
// b.N diatur otomatis oleh testing framework
// untuk mendapatkan hasil yang stabil
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
func BenchmarkSort(b *testing.B) {
// Setup di luar loop — tidak diukur
data := generateLargeSlice(10000)
b.ResetTimer() // reset timer setelah setup
for i := 0; i < b.N; i++ {
// Salin data karena sort memodifikasi slice
input := make([]int, len(data))
copy(input, data)
sort.Ints(input)
}
}
func BenchmarkWithAllocs(b *testing.B) {
b.ReportAllocs() // tampilkan statistik alokasi memori
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("hello %d", i) // alokasi string baru setiap kali
}
}
// Benchmark dengan berbagai ukuran input
func BenchmarkProcess(b *testing.B) {
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
data := generateData(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
process(data)
}
})
}
}
Menjalankan benchmark:
go test -bench . # semua benchmark
go test -bench BenchmarkSort # benchmark spesifik
go test -bench . -benchmem # tampilkan alokasi memori
go test -bench . -benchtime 5s # jalankan minimal 5 detik
go test -bench . -count 3 # ulangi 3 kali untuk akurasi
Output:
BenchmarkSort-8 10000 115234 ns/op 81920 B/op 1 allocs/op
^ ^ ^ ^
N ns per op bytes/op allocs/op
httptest — Test HTTP Handler
#
Package net/http/httptest memungkinkan test HTTP handler tanpa server nyata:
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
// Buat request dan response recorder
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
// Panggil handler langsung
healthHandler(rr, req)
// Periksa status code
if rr.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", rr.Code, http.StatusOK)
}
// Periksa response body
var resp map[string]string
json.NewDecoder(rr.Body).Decode(&resp)
if resp["status"] != "ok" {
t.Errorf("status = %q, want %q", resp["status"], "ok")
}
}
func TestCreateUserHandler(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
}{
{
name: "valid user",
body: `{"name":"Budi","email":"[email protected]"}`,
wantStatus: http.StatusCreated,
},
{
name: "missing name",
body: `{"email":"[email protected]"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid JSON",
body: `{invalid}`,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(
http.MethodPost, "/users",
strings.NewReader(tt.body),
)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
createUserHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("status = %d, want %d\nbody: %s",
rr.Code, tt.wantStatus, rr.Body.String())
}
})
}
}
// Test dengan httptest.NewServer untuk integrasi penuh
func TestAPIIntegration(t *testing.T) {
srv := httptest.NewServer(setupRouter())
defer srv.Close()
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
Code Coverage #
# Jalankan test dengan coverage
go test -cover ./...
# Output:
# ok myapp/calculator coverage: 87.5% of statements
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# Tampilkan per fungsi
go tool cover -func=coverage.out
# Tampilkan sebagai HTML di browser
go tool cover -html=coverage.out
# Set minimum coverage (useful di CI)
go test -cover ./... | grep -v "100.0%" | grep "coverage:"
Build Tags untuk Integration Test #
Pisahkan unit test (cepat) dari integration test (lambat, butuh infrastruktur):
// integration_test.go
//go:build integration
package myapp_test
import (
"testing"
"database/sql"
)
// Test ini hanya dijalankan dengan: go test -tags=integration ./...
func TestDatabaseIntegration(t *testing.T) {
db, err := sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Skip("DATABASE_URL tidak tersedia")
}
defer db.Close()
// ... test dengan database nyata
}
# Unit test saja (cepat)
go test ./...
# Termasuk integration test
go test -tags=integration ./...
Contoh Program Lengkap — Test Suite Payment Service #
// payment.go
package payment
import (
"errors"
"fmt"
"time"
)
type Currency string
const (
IDR Currency = "IDR"
USD Currency = "USD"
)
var (
ErrInsufficientFunds = errors.New("saldo tidak mencukupi")
ErrInvalidAmount = errors.New("jumlah tidak valid")
ErrAccountNotFound = errors.New("akun tidak ditemukan")
ErrSameAccount = errors.New("tidak bisa transfer ke akun yang sama")
)
type Account struct {
ID string
Name string
Balance float64
Currency Currency
}
type Transaction struct {
ID string
FromID string
ToID string
Amount float64
Currency Currency
CreatedAt time.Time
}
type Repository interface {
FindAccount(id string) (*Account, error)
UpdateBalance(id string, balance float64) error
SaveTransaction(tx Transaction) error
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Transfer(fromID, toID string, amount float64) (*Transaction, error) {
if amount <= 0 {
return nil, ErrInvalidAmount
}
if fromID == toID {
return nil, ErrSameAccount
}
from, err := s.repo.FindAccount(fromID)
if err != nil {
return nil, fmt.Errorf("akun pengirim: %w", err)
}
to, err := s.repo.FindAccount(toID)
if err != nil {
return nil, fmt.Errorf("akun penerima: %w", err)
}
if from.Balance < amount {
return nil, ErrInsufficientFunds
}
if err := s.repo.UpdateBalance(fromID, from.Balance-amount); err != nil {
return nil, fmt.Errorf("update pengirim: %w", err)
}
if err := s.repo.UpdateBalance(toID, to.Balance+amount); err != nil {
// Rollback
_ = s.repo.UpdateBalance(fromID, from.Balance)
return nil, fmt.Errorf("update penerima: %w", err)
}
tx := Transaction{
ID: fmt.Sprintf("TRX-%d", time.Now().UnixNano()),
FromID: fromID,
ToID: toID,
Amount: amount,
Currency: from.Currency,
CreatedAt: time.Now(),
}
if err := s.repo.SaveTransaction(tx); err != nil {
return nil, fmt.Errorf("simpan transaksi: %w", err)
}
return &tx, nil
}
// payment_test.go
package payment
import (
"errors"
"testing"
)
// ── In-Memory Repository untuk test ──────────────────────────
type mockRepo struct {
accounts map[string]*Account
transactions []Transaction
failUpdate string // ID akun yang sengaja gagal saat update
}
func newMockRepo(accounts ...*Account) *mockRepo {
r := &mockRepo{accounts: make(map[string]*Account)}
for _, a := range accounts {
r.accounts[a.ID] = a
}
return r
}
func (r *mockRepo) FindAccount(id string) (*Account, error) {
a, ok := r.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
// Return salinan agar tidak termodifikasi langsung
copy := *a
return ©, nil
}
func (r *mockRepo) UpdateBalance(id string, balance float64) error {
if r.failUpdate == id {
return errors.New("database error")
}
a, ok := r.accounts[id]
if !ok {
return ErrAccountNotFound
}
a.Balance = balance
return nil
}
func (r *mockRepo) SaveTransaction(tx Transaction) error {
r.transactions = append(r.transactions, tx)
return nil
}
// ── Helper ────────────────────────────────────────────────────
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("tidak mengharapkan error: %v", err)
}
}
func requireError(t *testing.T, err error, target error) {
t.Helper()
if err == nil {
t.Fatalf("mengharapkan error %v, tapi tidak ada error", target)
}
if !errors.Is(err, target) {
t.Fatalf("error = %v, want %v", err, target)
}
}
func assertBalance(t *testing.T, repo *mockRepo, accountID string, want float64) {
t.Helper()
a, err := repo.FindAccount(accountID)
if err != nil {
t.Fatalf("gagal cek saldo: %v", err)
}
if a.Balance != want {
t.Errorf("saldo %s = %.2f, want %.2f", accountID, a.Balance, want)
}
}
// ── Test Cases ────────────────────────────────────────────────
func TestTransfer(t *testing.T) {
tests := []struct {
name string
fromBalance float64
toBalance float64
amount float64
wantErr error
wantFromBal float64
wantToBal float64
}{
{
name: "transfer normal",
fromBalance: 1_000_000,
toBalance: 500_000,
amount: 300_000,
wantErr: nil,
wantFromBal: 700_000,
wantToBal: 800_000,
},
{
name: "saldo pas",
fromBalance: 500_000,
toBalance: 0,
amount: 500_000,
wantErr: nil,
wantFromBal: 0,
wantToBal: 500_000,
},
{
name: "saldo kurang",
fromBalance: 100_000,
toBalance: 500_000,
amount: 200_000,
wantErr: ErrInsufficientFunds,
wantFromBal: 100_000, // tidak berubah
wantToBal: 500_000,
},
{
name: "jumlah nol",
fromBalance: 1_000_000,
toBalance: 0,
amount: 0,
wantErr: ErrInvalidAmount,
wantFromBal: 1_000_000,
wantToBal: 0,
},
{
name: "jumlah negatif",
fromBalance: 1_000_000,
toBalance: 0,
amount: -50_000,
wantErr: ErrInvalidAmount,
wantFromBal: 1_000_000,
wantToBal: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newMockRepo(
&Account{ID: "A001", Name: "Budi", Balance: tt.fromBalance, Currency: IDR},
&Account{ID: "A002", Name: "Sari", Balance: tt.toBalance, Currency: IDR},
)
svc := NewService(repo)
_, err := svc.Transfer("A001", "A002", tt.amount)
if tt.wantErr != nil {
requireError(t, err, tt.wantErr)
} else {
requireNoError(t, err)
}
assertBalance(t, repo, "A001", tt.wantFromBal)
assertBalance(t, repo, "A002", tt.wantToBal)
})
}
}
func TestTransferValidation(t *testing.T) {
repo := newMockRepo(
&Account{ID: "A001", Balance: 1_000_000, Currency: IDR},
)
svc := NewService(repo)
t.Run("akun pengirim tidak ada", func(t *testing.T) {
_, err := svc.Transfer("TIDAK_ADA", "A001", 100_000)
requireError(t, err, ErrAccountNotFound)
})
t.Run("akun penerima tidak ada", func(t *testing.T) {
_, err := svc.Transfer("A001", "TIDAK_ADA", 100_000)
requireError(t, err, ErrAccountNotFound)
})
t.Run("transfer ke akun sendiri", func(t *testing.T) {
_, err := svc.Transfer("A001", "A001", 100_000)
requireError(t, err, ErrSameAccount)
})
}
func TestTransferRollback(t *testing.T) {
// Simulasi: update penerima gagal — saldo pengirim harus dikembalikan
repo := newMockRepo(
&Account{ID: "A001", Balance: 1_000_000, Currency: IDR},
&Account{ID: "A002", Balance: 500_000, Currency: IDR},
)
repo.failUpdate = "A002" // paksa gagal saat update A002
svc := NewService(repo)
_, err := svc.Transfer("A001", "A002", 300_000)
if err == nil {
t.Fatal("seharusnya ada error")
}
// Pastikan rollback berhasil — saldo A001 tidak berkurang
assertBalance(t, repo, "A001", 1_000_000)
assertBalance(t, repo, "A002", 500_000)
}
func BenchmarkTransfer(b *testing.B) {
repo := newMockRepo(
&Account{ID: "A001", Balance: 1e12, Currency: IDR},
&Account{ID: "A002", Balance: 1e12, Currency: IDR},
)
svc := NewService(repo)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Alternating transfer agar saldo tidak habis
if i%2 == 0 {
svc.Transfer("A001", "A002", 1000)
} else {
svc.Transfer("A002", "A001", 1000)
}
}
}
Ringkasan #
- Konvensi: file
_test.go, fungsi diawaliTest, parameter*testing.T— tidak perlu framework eksternal.- Table-driven tests adalah pola idiomatik Go — kelompokkan semua skenario dalam slice of struct, iterasi dengan
t.Run.t.Helper()wajib dipanggil di awal setiap fungsi helper agar error menunjuk ke pemanggil, bukan ke dalam helper.t.Parallel()untuk menjalankan subtest secara concurrent — tangkap loop variable (tt := tt) sebelum Go 1.22.TestMainuntuk setup/teardown global (database, server test) — jangan lupaos.Exit(m.Run()).- Benchmark: gunakan
b.ResetTimer()setelah setup,b.ReportAllocs()untuk statistik memori.httptest.NewRecorder()untuk test HTTP handler tanpa server nyata;httptest.NewServer()untuk integrasi penuh.-coverprofileuntuk laporan coverage;go tool cover -htmluntuk visualisasi interaktif.- Build tags (
//go:build integration) untuk memisahkan unit test dari integration test.- Test tertulis di package yang sama untuk white-box testing;
package_testuntuk black-box testing.