Mocking #

Mocking adalah teknik menggantikan dependency nyata (database, HTTP API, file system) dengan versi palsu yang perilakunya bisa dikontrol dalam test. Tanpa mocking, test kamu bergantung pada infrastruktur eksternal: database harus jalan, API harus online, file harus ada. Dengan mock, test bisa berjalan cepat, deterministik, dan offline. Di Go, mocking sangat natural karena interface — selama dependency diakses melalui interface, kamu bisa menukar implementasi nyata dengan implementasi mock kapan saja.

Interface sebagai Fondasi Testability #

Kode yang mudah di-mock adalah kode yang dependency-nya berupa interface, bukan concrete type:

// SULIT di-mock: dependency langsung ke concrete type
type UserService struct {
    db *sql.DB  // concrete — tidak bisa diganti saat test
}

// MUDAH di-mock: dependency melalui interface
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
    Delete(id int) error
}

type UserService struct {
    repo UserRepository  // interface — bisa diganti saat test
}

Prinsip: inject dependency melalui constructor, jangan buat dependency di dalam struct:

// ANTI-PATTERN: dependency dibuat di dalam — tidak bisa di-mock
func NewUserService() *UserService {
    return &UserService{
        repo: NewPostgresRepository(),  // hardcoded!
    }
}

// BENAR: dependency di-inject dari luar
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Production
svc := NewUserService(NewPostgresRepository(db))

// Test
svc := NewUserService(&MockRepository{})

Stub, Mock, Fake, dan Spy #

Sebelum masuk ke implementasi, penting memahami perbedaan istilah:

STUB
  Implementasi minimal yang mengembalikan nilai tetap.
  Tidak memverifikasi bagaimana ia dipanggil.
  Cocok untuk: "berikan nilai ini agar fungsi yang diuji bisa berjalan"

MOCK
  Implementasi yang memverifikasi ekspektasi pemanggilan.
  Bisa mengecek: apakah dipanggil? berapa kali? dengan argumen apa?
  Cocok untuk: "pastikan fungsi ini memanggil dependency dengan benar"

FAKE
  Implementasi yang benar-benar bekerja tapi lebih sederhana.
  Contoh: in-memory database sebagai pengganti PostgreSQL.
  Cocok untuk: integration test yang lebih ringan

SPY
  Membungkus implementasi nyata dan mencatat semua pemanggilan.
  Cocok untuk: memverifikasi interaksi tanpa mengganti perilaku asli

Manual Mock — Cara Paling Sederhana #

Untuk interface sederhana, buat struct yang mengimplementasikannya secara manual:

// Interface yang akan di-mock
type EmailSender interface {
    Send(to, subject, body string) error
}

// Manual mock — struct dengan field untuk kontrol perilaku
type MockEmailSender struct {
    // Kontrol nilai return
    ShouldFail bool
    ReturnErr  error

    // Spy — catat semua panggilan
    Calls []struct {
        To      string
        Subject string
        Body    string
    }
}

func (m *MockEmailSender) Send(to, subject, body string) error {
    // Catat panggilan
    m.Calls = append(m.Calls, struct {
        To      string
        Subject string
        Body    string
    }{to, subject, body})

    if m.ShouldFail {
        if m.ReturnErr != nil {
            return m.ReturnErr
        }
        return errors.New("email gagal dikirim")
    }
    return nil
}

// Helper methods untuk assert
func (m *MockEmailSender) CallCount() int {
    return len(m.Calls)
}

func (m *MockEmailSender) LastCall() (to, subject, body string) {
    if len(m.Calls) == 0 {
        return "", "", ""
    }
    last := m.Calls[len(m.Calls)-1]
    return last.To, last.Subject, last.Body
}

// Penggunaan dalam test
func TestRegisterUser(t *testing.T) {
    mockEmail := &MockEmailSender{}
    svc := NewUserService(mockEmail)

    err := svc.Register("[email protected]", "password123")
    if err != nil {
        t.Fatal(err)
    }

    // Verifikasi email dikirim
    if mockEmail.CallCount() != 1 {
        t.Errorf("email dikirim %d kali, want 1", mockEmail.CallCount())
    }

    to, subject, _ := mockEmail.LastCall()
    if to != "[email protected]" {
        t.Errorf("email dikirim ke %q, want %q", to, "[email protected]")
    }
    if !strings.Contains(subject, "Verifikasi") {
        t.Errorf("subject %q tidak mengandung 'Verifikasi'", subject)
    }
}

// Test dengan error
func TestRegisterUser_EmailFail(t *testing.T) {
    mockEmail := &MockEmailSender{
        ShouldFail: true,
        ReturnErr:  errors.New("SMTP server down"),
    }
    svc := NewUserService(mockEmail)

    err := svc.Register("[email protected]", "password123")
    if err == nil {
        t.Fatal("seharusnya error jika email gagal")
    }
}

Functional Mock — Mock dengan Function Field #

Alternatif yang lebih fleksibel: simpan perilaku sebagai field fungsi:

type MockNotifier struct {
    NotifyFn func(userID int, msg string) error
}

func (m *MockNotifier) Notify(userID int, msg string) error {
    if m.NotifyFn != nil {
        return m.NotifyFn(userID, msg)
    }
    return nil  // no-op default
}

// Test dengan perilaku kustom per test case
func TestProcessOrder(t *testing.T) {
    t.Run("notifikasi berhasil", func(t *testing.T) {
        notified := false
        mock := &MockNotifier{
            NotifyFn: func(userID int, msg string) error {
                notified = true
                return nil
            },
        }
        svc := NewOrderService(mock)
        svc.Process(orderID)

        if !notified {
            t.Error("seharusnya mengirim notifikasi")
        }
    })

    t.Run("lanjut meski notifikasi gagal", func(t *testing.T) {
        mock := &MockNotifier{
            NotifyFn: func(userID int, msg string) error {
                return errors.New("notification service down")
            },
        }
        svc := NewOrderService(mock)
        err := svc.Process(orderID)

        // Order seharusnya tetap berhasil meski notifikasi gagal
        if err != nil {
            t.Errorf("order gagal meski notifikasi tidak wajib: %v", err)
        }
    })
}

Mock HTTP Client #

Untuk meng-mock HTTP call, implementasikan http.RoundTripper:

// MockTransport merekam request dan mengembalikan response yang dikonfigurasi
type MockTransport struct {
    Requests  []*http.Request
    Response  *http.Response
    Err       error
}

func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    m.Requests = append(m.Requests, req)
    if m.Err != nil {
        return nil, m.Err
    }
    return m.Response, nil
}

// Helper untuk membuat mock response
func mockResponse(statusCode int, body string) *http.Response {
    return &http.Response{
        StatusCode: statusCode,
        Body:       io.NopCloser(strings.NewReader(body)),
        Header:     make(http.Header),
    }
}

// Penggunaan
func TestFetchUser(t *testing.T) {
    transport := &MockTransport{
        Response: mockResponse(200, `{"id":1,"name":"Budi"}`),
    }

    client := &http.Client{Transport: transport}
    svc := NewUserAPIClient(client)

    user, err := svc.FetchUser(1)
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Budi" {
        t.Errorf("name = %q, want %q", user.Name, "Budi")
    }

    // Verifikasi request yang dikirim
    if len(transport.Requests) != 1 {
        t.Errorf("request dikirim %d kali, want 1", len(transport.Requests))
    }
    req := transport.Requests[0]
    if req.Method != http.MethodGet {
        t.Errorf("method = %q, want GET", req.Method)
    }
    if !strings.HasSuffix(req.URL.Path, "/users/1") {
        t.Errorf("path = %q, tidak mengandung /users/1", req.URL.Path)
    }
}

// Test error scenario
func TestFetchUser_NetworkError(t *testing.T) {
    transport := &MockTransport{
        Err: errors.New("connection refused"),
    }
    client := &http.Client{Transport: transport}
    svc := NewUserAPIClient(client)

    _, err := svc.FetchUser(1)
    if err == nil {
        t.Fatal("seharusnya error")
    }
}

testify/mock — Mock dengan Ekspektasi #

Library testify menyediakan mock yang lebih ekspresif dengan sistem ekspektasi:

go get github.com/stretchr/testify
import "github.com/stretchr/testify/mock"

// Definisi mock
type MockUserRepo struct {
    mock.Mock
}

func (m *MockUserRepo) FindByID(id int) (*User, error) {
    args := m.Called(id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

func (m *MockUserRepo) Save(user *User) error {
    args := m.Called(user)
    return args.Error(0)
}

func (m *MockUserRepo) Delete(id int) error {
    args := m.Called(id)
    return args.Error(0)
}

// Test dengan ekspektasi
func TestGetUser(t *testing.T) {
    mockRepo := new(MockUserRepo)

    // Setup ekspektasi: FindByID(42) harus dipanggil sekali
    // dan mengembalikan user ini
    expectedUser := &User{ID: 42, Name: "Budi"}
    mockRepo.On("FindByID", 42).Return(expectedUser, nil).Once()

    svc := NewUserService(mockRepo)
    user, err := svc.GetUser(42)

    // Assert hasil
    assert.NoError(t, err)
    assert.Equal(t, "Budi", user.Name)

    // Verifikasi semua ekspektasi terpenuhi
    mockRepo.AssertExpectations(t)
}

func TestGetUser_NotFound(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockRepo.On("FindByID", 999).Return(nil, ErrNotFound)

    svc := NewUserService(mockRepo)
    _, err := svc.GetUser(999)

    assert.ErrorIs(t, err, ErrNotFound)
    mockRepo.AssertExpectations(t)
}

// Ekspektasi dengan matcher
func TestSaveUser(t *testing.T) {
    mockRepo := new(MockUserRepo)

    // Terima User apapun (mock.AnythingOfType atau mock.MatchedBy)
    mockRepo.On("Save", mock.MatchedBy(func(u *User) bool {
        return u.Email != "" // validasi: email tidak boleh kosong
    })).Return(nil)

    svc := NewUserService(mockRepo)
    err := svc.CreateUser("Budi", "[email protected]")

    assert.NoError(t, err)
    mockRepo.AssertExpectations(t)
}

gomock — Mock Generator #

gomock dari Google menghasilkan mock secara otomatis dari interface:

go install github.com/golang/mock/mockgen@latest
// repository.go
//go:generate mockgen -source=repository.go -destination=mock/repository_mock.go -package=mock

type PaymentRepository interface {
    FindTransaction(id string) (*Transaction, error)
    SaveTransaction(tx *Transaction) error
    UpdateStatus(id string, status string) error
}
# Generate mock
go generate ./...

# Hasilnya: mock/repository_mock.go
// Penggunaan mock yang di-generate
import "myapp/mock"

func TestProcessPayment(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()  // verifikasi semua ekspektasi di akhir

    mockRepo := mock.NewMockPaymentRepository(ctrl)

    // Setup ekspektasi
    mockRepo.EXPECT().
        FindTransaction("TRX-001").
        Return(&Transaction{ID: "TRX-001", Amount: 100_000}, nil).
        Times(1)

    mockRepo.EXPECT().
        UpdateStatus("TRX-001", "SUCCESS").
        Return(nil).
        Times(1)

    svc := NewPaymentService(mockRepo)
    err := svc.Process("TRX-001")
    assert.NoError(t, err)
    // ctrl.Finish() otomatis memverifikasi semua ekspektasi
}

Contoh Program Lengkap #

Program berikut mensimulasikan layanan e-commerce dengan mock repository, mock notifier, dan mock payment gateway:

package ecommerce_test

import (
    "errors"
    "testing"
    "time"
)

// ── Domain ────────────────────────────────────────────────────

type Order struct {
    ID         string
    UserID     int
    Items      []OrderItem
    Total      float64
    Status     string
    CreatedAt  time.Time
}

type OrderItem struct {
    ProductID int
    Qty       int
    Price     float64
}

var (
    ErrOrderNotFound   = errors.New("order tidak ditemukan")
    ErrPaymentFailed   = errors.New("pembayaran gagal")
    ErrStockInsufficient = errors.New("stok tidak cukup")
)

// ── Interfaces ────────────────────────────────────────────────

type OrderRepository interface {
    FindByID(id string) (*Order, error)
    Save(order *Order) error
    UpdateStatus(id, status string) error
}

type PaymentGateway interface {
    Charge(orderID string, amount float64) (string, error) // returns payment ID
}

type Notifier interface {
    Notify(userID int, message string) error
}

type StockChecker interface {
    IsAvailable(productID, qty int) (bool, error)
}

// ── Service ───────────────────────────────────────────────────

type OrderService struct {
    repo     OrderRepository
    payment  PaymentGateway
    notifier Notifier
    stock    StockChecker
}

func NewOrderService(
    repo OrderRepository,
    payment PaymentGateway,
    notifier Notifier,
    stock StockChecker,
) *OrderService {
    return &OrderService{repo, payment, notifier, stock}
}

func (s *OrderService) Checkout(order *Order) error {
    // Cek stok setiap item
    for _, item := range order.Items {
        ok, err := s.stock.IsAvailable(item.ProductID, item.Qty)
        if err != nil {
            return fmt.Errorf("cek stok: %w", err)
        }
        if !ok {
            return ErrStockInsufficient
        }
    }

    // Simpan order
    order.Status = "PENDING"
    if err := s.repo.Save(order); err != nil {
        return fmt.Errorf("simpan order: %w", err)
    }

    // Proses pembayaran
    _, err := s.payment.Charge(order.ID, order.Total)
    if err != nil {
        _ = s.repo.UpdateStatus(order.ID, "PAYMENT_FAILED")
        return ErrPaymentFailed
    }

    // Update status
    if err := s.repo.UpdateStatus(order.ID, "PAID"); err != nil {
        return fmt.Errorf("update status: %w", err)
    }

    // Kirim notifikasi (tidak perlu return error)
    _ = s.notifier.Notify(order.UserID, "Pesananmu berhasil dibayar!")

    return nil
}

// ── Mocks ─────────────────────────────────────────────────────

type mockOrderRepo struct {
    orders     map[string]*Order
    saveCalled int
    updateLog  []struct{ id, status string }
    saveFails  bool
    updateFails bool
}

func newMockOrderRepo() *mockOrderRepo {
    return &mockOrderRepo{orders: make(map[string]*Order)}
}

func (m *mockOrderRepo) FindByID(id string) (*Order, error) {
    o, ok := m.orders[id]
    if !ok {
        return nil, ErrOrderNotFound
    }
    return o, nil
}

func (m *mockOrderRepo) Save(order *Order) error {
    if m.saveFails {
        return errors.New("db error")
    }
    m.saveCalled++
    m.orders[order.ID] = order
    return nil
}

func (m *mockOrderRepo) UpdateStatus(id, status string) error {
    if m.updateFails {
        return errors.New("db error")
    }
    m.updateLog = append(m.updateLog, struct{ id, status string }{id, status})
    if o, ok := m.orders[id]; ok {
        o.Status = status
    }
    return nil
}

type mockPaymentGateway struct {
    shouldFail bool
    charged    []struct{ orderID string; amount float64 }
}

func (m *mockPaymentGateway) Charge(orderID string, amount float64) (string, error) {
    m.charged = append(m.charged, struct{ orderID string; amount float64 }{orderID, amount})
    if m.shouldFail {
        return "", errors.New("kartu ditolak")
    }
    return "PAY-" + orderID, nil
}

type mockNotifier struct {
    notifications []struct{ userID int; msg string }
    shouldFail    bool
}

func (m *mockNotifier) Notify(userID int, msg string) error {
    m.notifications = append(m.notifications, struct{ userID int; msg string }{userID, msg})
    if m.shouldFail {
        return errors.New("notification failed")
    }
    return nil
}

type mockStockChecker struct {
    available map[int]bool
}

func (m *mockStockChecker) IsAvailable(productID, qty int) (bool, error) {
    return m.available[productID], nil
}

// ── Tests ─────────────────────────────────────────────────────

func makeOrder() *Order {
    return &Order{
        ID:     "ORD-001",
        UserID: 42,
        Items:  []OrderItem{{ProductID: 1, Qty: 2, Price: 50_000}},
        Total:  100_000,
    }
}

func TestCheckout_Success(t *testing.T) {
    repo := newMockOrderRepo()
    pay := &mockPaymentGateway{}
    notif := &mockNotifier{}
    stock := &mockStockChecker{available: map[int]bool{1: true}}

    svc := NewOrderService(repo, pay, notif, stock)
    order := makeOrder()

    err := svc.Checkout(order)
    if err != nil {
        t.Fatalf("checkout gagal: %v", err)
    }

    // Verifikasi repo
    if repo.saveCalled != 1 {
        t.Errorf("Save() dipanggil %d kali, want 1", repo.saveCalled)
    }
    savedOrder, _ := repo.FindByID("ORD-001")
    if savedOrder.Status != "PAID" {
        t.Errorf("status = %q, want PAID", savedOrder.Status)
    }

    // Verifikasi payment
    if len(pay.charged) != 1 {
        t.Errorf("Charge() dipanggil %d kali, want 1", len(pay.charged))
    }
    if pay.charged[0].amount != 100_000 {
        t.Errorf("amount = %.0f, want 100000", pay.charged[0].amount)
    }

    // Verifikasi notifikasi
    if len(notif.notifications) != 1 {
        t.Errorf("Notify() dipanggil %d kali, want 1", len(notif.notifications))
    }
}

func TestCheckout_StockInsufficient(t *testing.T) {
    repo := newMockOrderRepo()
    pay := &mockPaymentGateway{}
    notif := &mockNotifier{}
    stock := &mockStockChecker{available: map[int]bool{1: false}} // stok habis

    svc := NewOrderService(repo, pay, notif, stock)
    err := svc.Checkout(makeOrder())

    if !errors.Is(err, ErrStockInsufficient) {
        t.Errorf("error = %v, want ErrStockInsufficient", err)
    }
    // Pastikan tidak ada yang dipanggil
    if repo.saveCalled != 0 {
        t.Error("Save() tidak seharusnya dipanggil jika stok kurang")
    }
    if len(pay.charged) != 0 {
        t.Error("Charge() tidak seharusnya dipanggil jika stok kurang")
    }
}

func TestCheckout_PaymentFailed(t *testing.T) {
    repo := newMockOrderRepo()
    pay := &mockPaymentGateway{shouldFail: true}
    notif := &mockNotifier{}
    stock := &mockStockChecker{available: map[int]bool{1: true}}

    svc := NewOrderService(repo, pay, notif, stock)
    err := svc.Checkout(makeOrder())

    if !errors.Is(err, ErrPaymentFailed) {
        t.Errorf("error = %v, want ErrPaymentFailed", err)
    }

    // Order harus tersimpan dengan status PAYMENT_FAILED
    savedOrder, _ := repo.FindByID("ORD-001")
    if savedOrder == nil || savedOrder.Status != "PAYMENT_FAILED" {
        t.Errorf("status = %v, want PAYMENT_FAILED", savedOrder)
    }

    // Notifikasi tidak boleh dikirim
    if len(notif.notifications) != 0 {
        t.Error("Notify() tidak seharusnya dipanggil jika pembayaran gagal")
    }
}

func TestCheckout_NotificationFailedIsOK(t *testing.T) {
    // Checkout harus tetap sukses meski notifikasi gagal
    repo := newMockOrderRepo()
    pay := &mockPaymentGateway{}
    notif := &mockNotifier{shouldFail: true}
    stock := &mockStockChecker{available: map[int]bool{1: true}}

    svc := NewOrderService(repo, pay, notif, stock)
    err := svc.Checkout(makeOrder())

    if err != nil {
        t.Errorf("checkout gagal meski notifikasi gagal: %v", err)
    }
}

Ringkasan #

  • Interface adalah fondasi testability — dependency harus berupa interface, bukan concrete type.
  • Dependency injection melalui constructor — jangan buat dependency di dalam struct.
  • Manual mock cukup untuk interface kecil — struct yang implement interface dengan field untuk kontrol perilaku.
  • Functional mock (field bertipe func) lebih fleksibel — perilaku bisa dikustomisasi per test case.
  • Mock HTTP client via http.RoundTripper — tidak perlu server nyata untuk test HTTP call.
  • testify/mock untuk ekspektasi yang ekspresif — On, Return, Once, Times, AssertExpectations.
  • gomock untuk generate mock otomatis dari interface — tidak perlu tulis manual.
  • //go:generate mockgen ... untuk regenerate mock saat interface berubah.
  • Stub = nilai return tetap; Mock = verifikasi ekspektasi; Fake = implementasi nyata tapi sederhana; Spy = catat tanpa ubah perilaku.
  • Test seharusnya gagal karena alasan yang jelas — setiap verifikasi mock harus punya pesan error yang informatif.

← Sebelumnya: Unit Test   Berikutnya: JSON →

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