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/mockuntuk ekspektasi yang ekspresif —On,Return,Once,Times,AssertExpectations.gomockuntuk 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.