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/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.