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.

Perbedaan struktur dependensi antara pengetesan integrasi (dengan database nyata) dan pengujian unit (menggunakan Mocking via Interface) dapat divisualisasikan dalam diagram berikut:

flowchart TD
    subgraph RealTest ["Pengujian Nyata (Real Dependency - Lambat & Flaky)"]
        USReal["UserService"] -->|"Panggil Query"| DBReal[("Database Postgres Nyata")]
    end

    subgraph MockTest ["Pengujian Unit (Mocking - Cepat & Terisolasi)"]
        USMock["UserService"] -->|"Interface"| RepoIntf["UserRepository (Interface)"]
        RepoIntf -->|"Inject"| MockDB["UserRepositoryMock (Palsu / Dikontrol)"]
        
        style MockDB fill:#eef9ff,stroke:#007acc,stroke-width:2px
    end

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