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 sebelum t.Parallel(). Sebelum Go 1.22, variabel loop di-share antar iterasi — tanpa tt := tt, semua goroutine akan menggunakan nilai tt yang 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 &copy, 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 diawali Test, 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.
  • TestMain untuk setup/teardown global (database, server test) — jangan lupa os.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.
  • -coverprofile untuk laporan coverage; go tool cover -html untuk 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_test untuk black-box testing.

← Sebelumnya: Web Server   Berikutnya: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact