Web Artisan
Beranda

Progress belajar

Modul 51 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 6 · Testing

Testing Service Layer
dengan Mock Repository

Di modul ini kita menguji keputusan bisnis checkout, payment webhook, dan inventory tanpa database, tanpa HTTP server, dan tanpa payment gateway.

Bahasa: Go 1.26~70 menit baca
01

Kenapa Service Layer Perlu Ditest?

Handler memastikan request dan response benar, tetapi service memastikan bisnis tidak bocor.

Di online shop skincare, bug paling mahal biasanya bukan typo JSON. Bug paling mahal adalah order bisa dibuat saat stok kurang, voucher expired tetap diterima, payment yang sama dihitung dua kali, atau stok berkurang ganda ketika dependency lambat. Semua keputusan seperti itu hidup di service layer.

Pada modul sebelumnya, kamu menguji HTTP handler dengan httptest. Di sini kita turun satu lapis. Kita panggil method service langsung, lalu dependency database diganti dengan implementasi fake yang memenuhi interface repository.

🌉Jembatan: dari Jest mock ke Go interface

Di Jest kamu sering mengganti function dengan jest.fn, dan di PHPUnit kamu memakai createMock dari container. Di Go, pola yang idiomatik adalah membuat service bergantung pada interface kecil, lalu test menyediakan struct fake yang punya method sama, dan compiler yang memeriksa kecocokannya.

service layer

Lapisan yang menjalankan business logic, seperti validasi stok, hitung total, cek voucher, verifikasi status pembayaran, dan menahan stok, tanpa tahu detail HTTP maupun SQL.

fake repository

Implementasi repository untuk test yang menyimpan data di memory, biasanya map, sehingga test cepat dan deterministik.

Modul ini menyentuh tiga service inti proyek sekaligus: checkout, payment webhook, dan inventory. Ketiganya memakai pola yang sama, yaitu interface kecil plus fake repository, sehingga setelah satu pola kamu kuasai, dua sisanya hanya variasi domain.

02

Posisi Service Layer dalam Test Pyramid

Test service berada di tengah: lebih realistis dari unit pure function, lebih cepat dari integration test database.

Package testing bawaan Go menjalankan fungsi TestXxx dari file berakhiran _test.go. Untuk service layer, kita tetap memakai package bawaan ini. Yang berubah hanya objek yang kita siapkan sebelum memanggil method service.

flowchart TD
  A["Unit test pure logic"] --> B["Service test dengan fake repository"]
  B --> C["Handler test dengan httptest"]
  C --> D["Integration test dengan PostgreSQL"]
  D --> E["End-to-end test"]
  B -. "fokus modul ini" .-> F["checkout, payment webhook, inventory"]

Gambar 1. Service test memberi sinyal cepat saat business rule rusak, sebelum kita menyentuh database atau HTTP. Di modul ini ia menjaga tiga service inti sekaligus.

Lebih cepat

Tidak membuka koneksi PostgreSQL, tidak menjalankan migrasi, dan tidak butuh data seed besar.

Lebih fokus

Gagal karena business rule, bukan karena DSN, transaksi, atau skema database yang belum siap.

Lebih aman di refactor

Repository boleh pindah dari pgx ke query builder lain selama kontrak interface tetap sama.

POST /v1/checkout Handler memanggil service checkout, tetapi test modul ini langsung menguji service tanpa HTTP layer
POST /v1/webhooks/payment Handler memvalidasi signature lalu memanggil service webhook, yang business rule-nya kita uji langsung di sini
03

Interface dan Fake Repository

Interface adalah seam, titik sambungan yang membuat dependency bisa diganti saat test.

Di Go, interface biasanya didefinisikan di sisi pemakai, bukan di sisi implementasi. Service checkout butuh kemampuan mencari produk, menahan stok, mencari voucher, dan menyimpan order. Maka service mendefinisikan interface kecil sesuai kebutuhannya.

JS / PHP
  • Mock sering dibuat dari library, container, monkey patch, atau object literal.
  • Kontrak kadang baru ketahuan saat runtime jika bentuk object tidak cocok.
Go
  • Fake adalah struct biasa yang mengimplementasikan interface secara implisit.
  • Compiler memastikan fake punya method yang dibutuhkan service.
💡Idiom Go

Accept interfaces, return structs. Service menerima interface dependency, tetapi constructor service mengembalikan concrete struct agar API internal tetap jelas.

Struktur file untuk test service
  • internal/
  • checkout/
  • service.go business logic checkout
  • service_test.go fake repository dan test checkout
  • payment/
  • service.go verifikasi status dan idempotensi webhook
  • service_test.go fake order dan event repository
  • inventory/
  • service.go reserve dan release stok
  • service_test.go fake stock repository
  • product/
  • repository.go implementasi pgx asli di roadmap database
04

Skenario Checkout yang Akan Diuji

Kita tidak mengetes semua detail checkout sekaligus, kita pilih tiga jalur yang paling mewakili risiko bisnis.

Skenario happy path memastikan order berhasil dibuat, subtotal dihitung dari snapshot harga produk, voucher mengurangi total, dan stok berkurang. Failure path stok kurang memastikan order tidak dibuat dan stok tidak berubah. Edge case voucher expired memastikan validasi voucher terjadi sebelum stok direserve.

sequenceDiagram
  participant Test as Test
  participant Service as CheckoutService
  participant ProductRepo as FakeProductRepository
  participant VoucherRepo as FakeVoucherRepository
  participant OrderRepo as FakeOrderRepository
  Test->>Service: Checkout(ctx, request)
  Service->>ProductRepo: FindByID(productID)
  ProductRepo-->>Service: Product price and stock
  Service->>VoucherRepo: FindByCode(code)
  VoucherRepo-->>Service: Voucher expiry and discount
  Service->>ProductRepo: ReserveStock(productID, qty)
  Service->>OrderRepo: Save(order)
  OrderRepo-->>Service: Order with ID
  Service-->>Test: Order or error

Gambar 2. Semua dependency di diagram adalah fake saat test, sehingga alur bisnis bisa dicek tanpa database.

Happy path

Produk tersedia, voucher masih berlaku, order tersimpan, dan stok produk turun sesuai quantity.

Failure path

Stok kurang, service mengembalikan ErrInsufficientStock, order tidak tersimpan, dan stok tetap.

Edge case

Voucher expired, service mengembalikan ErrVoucherExpired, stok belum direserve, dan order tidak dibuat.

05

Service Checkout yang Testable

Service berikut sengaja kecil, tetapi sudah menunjukkan bentuk dependency injection yang akan dipakai di proyek nyata.

Perhatikan bahwa context.Context menjadi parameter pertama pada method repository dan service. Ini memudahkan handler meneruskan cancelation dari HTTP request, dan nanti berguna saat repository asli memakai pgx. Harga memakai tipe kanonik PriceRupiah int64, yaitu rupiah satuan utuh, bukan cents, agar konsisten dengan modul katalog dan checkout di Roadmap 5.

internal/checkout/service.go
package checkout import ( "context" "errors" "time" ) type PriceRupiah int64 type ProductID string type UserID string var ( ErrEmptyCheckout = errors.New("checkout is empty") ErrInvalidQuantity = errors.New("checkout item quantity must be positive") ErrProductNotFound = errors.New("product not found") ErrInsufficientStock = errors.New("insufficient stock") ErrVoucherNotFound = errors.New("voucher not found") ErrVoucherExpired = errors.New("voucher expired") ) type Product struct { ID ProductID Name string Price PriceRupiah Stock int } type Voucher struct { Code string DiscountAmount PriceRupiah ExpiresAt time.Time } type CheckoutItem struct { ProductID ProductID Quantity int } type CheckoutRequest struct { UserID UserID Items []CheckoutItem VoucherCode string Now time.Time } type OrderItem struct { ProductID ProductID Quantity int UnitPrice PriceRupiah LineTotal PriceRupiah } type Order struct { ID string UserID UserID Items []OrderItem Subtotal PriceRupiah Discount PriceRupiah Total PriceRupiah } type ProductRepository interface { FindByID(ctx context.Context, id ProductID) (Product, error) ReserveStock(ctx context.Context, id ProductID, quantity int) error } type VoucherRepository interface { FindByCode(ctx context.Context, code string) (Voucher, error) } type OrderRepository interface { Save(ctx context.Context, order Order) (Order, error) } type Service struct { products ProductRepository vouchers VoucherRepository orders OrderRepository } func NewService(products ProductRepository, vouchers VoucherRepository, orders OrderRepository) *Service { return &Service{ products: products, vouchers: vouchers, orders: orders, } } func (s *Service) Checkout(ctx context.Context, req CheckoutRequest) (Order, error) { if len(req.Items) == 0 { return Order{}, ErrEmptyCheckout } now := req.Now if now.IsZero() { now = time.Now() } order := Order{ UserID: req.UserID, Items: make([]OrderItem, 0, len(req.Items)), } for _, item := range req.Items { if item.Quantity <= 0 { return Order{}, ErrInvalidQuantity } product, err := s.products.FindByID(ctx, item.ProductID) if err != nil { return Order{}, err } if product.Stock < item.Quantity { return Order{}, ErrInsufficientStock } lineTotal := product.Price * PriceRupiah(item.Quantity) order.Items = append(order.Items, OrderItem{ ProductID: product.ID, Quantity: item.Quantity, UnitPrice: product.Price, LineTotal: lineTotal, }) order.Subtotal += lineTotal } if req.VoucherCode != "" { voucher, err := s.vouchers.FindByCode(ctx, req.VoucherCode) if err != nil { return Order{}, err } if !voucher.ExpiresAt.After(now) { return Order{}, ErrVoucherExpired } order.Discount = voucher.DiscountAmount if order.Discount > order.Subtotal { order.Discount = order.Subtotal } } order.Total = order.Subtotal - order.Discount for _, item := range req.Items { if err := s.products.ReserveStock(ctx, item.ProductID, item.Quantity); err != nil { return Order{}, err } } return s.orders.Save(ctx, order) }
⚠️Urutan business rule penting

Voucher divalidasi sebelum ReserveStock, sehingga voucher expired tidak akan mengurangi stok. Test edge case nanti menjaga urutan ini agar tidak rusak saat refactor.

06

Fake Repository dan Test Checkout

Fake repository berikut bukan library khusus, hanya struct Go biasa yang menyimpan state di memory.

File test ini menguji tiga jalur checkout. Tidak ada koneksi DB, tidak ada migration, tidak ada HTTP server. Semua sinyal datang dari return value, error, dan state fake repository setelah service dipanggil. Perhatikan pengecekan error selalu memakai errors.Is, bukan perbandingan string, sehingga tetap benar walau error dibungkus dengan fmt.Errorf("...: %w", err) di repository asli nanti.

internal/checkout/service_test.go
package checkout import ( "context" "errors" "testing" "time" ) type reserveCall struct { ProductID ProductID Quantity int } type FakeProductRepository struct { products map[ProductID]Product reserveCalls []reserveCall } func NewFakeProductRepository(products []Product) *FakeProductRepository { items := make(map[ProductID]Product, len(products)) for _, product := range products { items[product.ID] = product } return &FakeProductRepository{products: items} } func (r *FakeProductRepository) FindByID(ctx context.Context, id ProductID) (Product, error) { product, ok := r.products[id] if !ok { return Product{}, ErrProductNotFound } return product, nil } func (r *FakeProductRepository) ReserveStock(ctx context.Context, id ProductID, quantity int) error { product, ok := r.products[id] if !ok { return ErrProductNotFound } if product.Stock < quantity { return ErrInsufficientStock } product.Stock -= quantity r.products[id] = product r.reserveCalls = append(r.reserveCalls, reserveCall{ProductID: id, Quantity: quantity}) return nil } type FakeVoucherRepository struct { vouchers map[string]Voucher } func NewFakeVoucherRepository(vouchers []Voucher) *FakeVoucherRepository { items := make(map[string]Voucher, len(vouchers)) for _, voucher := range vouchers { items[voucher.Code] = voucher } return &FakeVoucherRepository{vouchers: items} } func (r *FakeVoucherRepository) FindByCode(ctx context.Context, code string) (Voucher, error) { voucher, ok := r.vouchers[code] if !ok { return Voucher{}, ErrVoucherNotFound } return voucher, nil } type FakeOrderRepository struct { saved []Order } func (r *FakeOrderRepository) Save(ctx context.Context, order Order) (Order, error) { if order.ID == "" { order.ID = "ord_test_001" } r.saved = append(r.saved, order) return order, nil } func TestService_Checkout_Success(t *testing.T) { t.Parallel() now := time.Date(2026, time.June, 6, 10, 0, 0, 0, time.UTC) productRepo := NewFakeProductRepository([]Product{ {ID: "prod_toner", Name: "Wardah Hydrating Toner", Price: 125000, Stock: 10}, }) voucherRepo := NewFakeVoucherRepository([]Voucher{ {Code: "SKINCARE25", DiscountAmount: 25000, ExpiresAt: now.Add(24 * time.Hour)}, }) orderRepo := &FakeOrderRepository{} svc := NewService(productRepo, voucherRepo, orderRepo) got, err := svc.Checkout(context.Background(), CheckoutRequest{ UserID: "user_1", VoucherCode: "SKINCARE25", Now: now, Items: []CheckoutItem{ {ProductID: "prod_toner", Quantity: 2}, }, }) if err != nil { t.Fatalf("Checkout() error = %v", err) } if got.ID != "ord_test_001" { t.Fatalf("Checkout() order ID = %q, want %q", got.ID, "ord_test_001") } if got.Subtotal != 250000 { t.Fatalf("Checkout() subtotal = %d, want %d", got.Subtotal, PriceRupiah(250000)) } if got.Discount != 25000 { t.Fatalf("Checkout() discount = %d, want %d", got.Discount, PriceRupiah(25000)) } if got.Total != 225000 { t.Fatalf("Checkout() total = %d, want %d", got.Total, PriceRupiah(225000)) } if len(orderRepo.saved) != 1 { t.Fatalf("saved orders = %d, want %d", len(orderRepo.saved), 1) } if productRepo.products["prod_toner"].Stock != 8 { t.Fatalf("remaining stock = %d, want %d", productRepo.products["prod_toner"].Stock, 8) } if len(productRepo.reserveCalls) != 1 { t.Fatalf("reserve calls = %d, want %d", len(productRepo.reserveCalls), 1) } } func TestService_Checkout_FailsWhenStockIsInsufficient(t *testing.T) { t.Parallel() now := time.Date(2026, time.June, 6, 10, 0, 0, 0, time.UTC) productRepo := NewFakeProductRepository([]Product{ {ID: "prod_serum", Name: "Brightening Serum", Price: 99000, Stock: 1}, }) voucherRepo := NewFakeVoucherRepository(nil) orderRepo := &FakeOrderRepository{} svc := NewService(productRepo, voucherRepo, orderRepo) _, err := svc.Checkout(context.Background(), CheckoutRequest{ UserID: "user_1", Now: now, Items: []CheckoutItem{ {ProductID: "prod_serum", Quantity: 2}, }, }) if !errors.Is(err, ErrInsufficientStock) { t.Fatalf("Checkout() error = %v, want %v", err, ErrInsufficientStock) } if len(orderRepo.saved) != 0 { t.Fatalf("saved orders = %d, want %d", len(orderRepo.saved), 0) } if productRepo.products["prod_serum"].Stock != 1 { t.Fatalf("remaining stock = %d, want %d", productRepo.products["prod_serum"].Stock, 1) } if len(productRepo.reserveCalls) != 0 { t.Fatalf("reserve calls = %d, want %d", len(productRepo.reserveCalls), 0) } } func TestService_Checkout_FailsWhenVoucherExpired(t *testing.T) { t.Parallel() now := time.Date(2026, time.June, 6, 10, 0, 0, 0, time.UTC) productRepo := NewFakeProductRepository([]Product{ {ID: "prod_moisturizer", Name: "Barrier Moisturizer", Price: 150000, Stock: 5}, }) voucherRepo := NewFakeVoucherRepository([]Voucher{ {Code: "EXPIRED10", DiscountAmount: 10000, ExpiresAt: now.Add(-time.Hour)}, }) orderRepo := &FakeOrderRepository{} svc := NewService(productRepo, voucherRepo, orderRepo) _, err := svc.Checkout(context.Background(), CheckoutRequest{ UserID: "user_1", VoucherCode: "EXPIRED10", Now: now, Items: []CheckoutItem{ {ProductID: "prod_moisturizer", Quantity: 1}, }, }) if !errors.Is(err, ErrVoucherExpired) { t.Fatalf("Checkout() error = %v, want %v", err, ErrVoucherExpired) } if len(orderRepo.saved) != 0 { t.Fatalf("saved orders = %d, want %d", len(orderRepo.saved), 0) } if productRepo.products["prod_moisturizer"].Stock != 5 { t.Fatalf("remaining stock = %d, want %d", productRepo.products["prod_moisturizer"].Stock, 5) } if len(productRepo.reserveCalls) != 0 { t.Fatalf("reserve calls = %d, want %d", len(productRepo.reserveCalls), 0) } }
🧭Kenapa tidak pakai database?

Test ini bertanya apakah service mengambil keputusan yang benar. Pertanyaan apakah SQL dan transaksi benar akan dijawab di integration test pada chapter berikutnya.

07

Menguji Payment Webhook dan Idempotensi

Payment gateway boleh mengirim notifikasi yang sama berkali-kali, jadi service webhook harus aman diproses ulang.

Gateway seperti Midtrans atau Xendit mengirim webhook untuk memberi tahu status pembayaran. Jaringan tidak sempurna, jadi notifikasi yang sama bisa datang dua kali atau lebih. Tugas service webhook adalah memverifikasi status, lalu menandai order sebagai dibayar tepat satu kali, walaupun event yang sama tiba berulang. Sifat ini disebut idempoten.

idempoten

Operasi yang menghasilkan keadaan akhir sama walau dijalankan satu kali atau berkali-kali. Memproses event pembayaran kedua tidak boleh menambah catatan atau mengubah status order yang sudah final.

🌉Jembatan: webhook di Express atau Laravel

Di Node atau Laravel, kamu mungkin mengandalkan unique index database untuk menolak event ganda dan baru ketahuan saat insert gagal. Di service test kita angkat keputusan itu ke level business logic, dengan fake event repository yang ingat event apa yang sudah diproses, sehingga jalur idempotensi bisa diuji tanpa database.

flowchart TD
  A["Webhook event tiba"] --> B{"Status = settlement?"}
  B -- "tidak" --> C["Abaikan, kembalikan nil"]
  B -- "ya" --> D{"Event ID sudah pernah diproses?"}
  D -- "ya" --> E["Idempoten: kembalikan nil tanpa ubah order"]
  D -- "tidak" --> F["Tandai order PAID, catat event ID"]

Gambar 3. Dua percabangan inti webhook: verifikasi status lalu cek duplikasi. Test wajib menutup kedua cabang ya dan tidak.

Service berikut bergantung pada dua interface kecil. OrderRepository mengubah status order, dan ProcessedEventRepository mengingat event yang sudah diproses.

internal/payment/service.go
package payment import ( "context" "errors" ) var ( ErrOrderNotFound = errors.New("order not found") ErrAmountMismatch = errors.New("paid amount does not match order total") ErrUnknownGateway = errors.New("unknown gateway status") ) type WebhookEvent struct { EventID string OrderID string Status string GrossAmount int64 } type OrderRepository interface { FindTotal(ctx context.Context, orderID string) (int64, error) MarkPaid(ctx context.Context, orderID string) error } type ProcessedEventRepository interface { Exists(ctx context.Context, eventID string) (bool, error) Record(ctx context.Context, eventID string) error } type Service struct { orders OrderRepository events ProcessedEventRepository } func NewService(orders OrderRepository, events ProcessedEventRepository) *Service { return &Service{orders: orders, events: events} } func (s *Service) HandleWebhook(ctx context.Context, ev WebhookEvent) error { switch ev.Status { case "settlement", "capture": // status sukses, lanjut proses case "pending", "deny", "expire", "cancel": return nil default: return ErrUnknownGateway } processed, err := s.events.Exists(ctx, ev.EventID) if err != nil { return err } if processed { return nil } total, err := s.orders.FindTotal(ctx, ev.OrderID) if err != nil { return err } if ev.GrossAmount != total { return ErrAmountMismatch } if err := s.orders.MarkPaid(ctx, ev.OrderID); err != nil { return err } return s.events.Record(ctx, ev.EventID) }

Test menutup empat jalur: sukses sekali bayar, idempotensi saat event berulang, edge case amount tidak cocok, dan status non-final yang diabaikan. Fake order repository mencatat berapa kali MarkPaid dipanggil, yang adalah bukti utama idempotensi.

internal/payment/service_test.go
package payment import ( "context" "errors" "testing" ) type FakeOrderRepository struct { total int64 exists bool markPaidN int } func (r *FakeOrderRepository) FindTotal(ctx context.Context, orderID string) (int64, error) { if !r.exists { return 0, ErrOrderNotFound } return r.total, nil } func (r *FakeOrderRepository) MarkPaid(ctx context.Context, orderID string) error { r.markPaidN++ return nil } type FakeProcessedEventRepository struct { seen map[string]bool } func newFakeEvents() *FakeProcessedEventRepository { return &FakeProcessedEventRepository{seen: make(map[string]bool)} } func (r *FakeProcessedEventRepository) Exists(ctx context.Context, eventID string) (bool, error) { return r.seen[eventID], nil } func (r *FakeProcessedEventRepository) Record(ctx context.Context, eventID string) error { r.seen[eventID] = true return nil } func TestService_HandleWebhook_MarksOrderPaidOnce(t *testing.T) { t.Parallel() orders := &FakeOrderRepository{total: 225000, exists: true} events := newFakeEvents() svc := NewService(orders, events) ev := WebhookEvent{EventID: "evt_1", OrderID: "ord_1", Status: "settlement", GrossAmount: 225000} if err := svc.HandleWebhook(context.Background(), ev); err != nil { t.Fatalf("first HandleWebhook() error = %v", err) } if err := svc.HandleWebhook(context.Background(), ev); err != nil { t.Fatalf("second HandleWebhook() error = %v", err) } if orders.markPaidN != 1 { t.Fatalf("MarkPaid called %d times, want 1 (must be idempotent)", orders.markPaidN) } } func TestService_HandleWebhook_RejectsAmountMismatch(t *testing.T) { t.Parallel() orders := &FakeOrderRepository{total: 225000, exists: true} events := newFakeEvents() svc := NewService(orders, events) ev := WebhookEvent{EventID: "evt_2", OrderID: "ord_1", Status: "settlement", GrossAmount: 100000} err := svc.HandleWebhook(context.Background(), ev) if !errors.Is(err, ErrAmountMismatch) { t.Fatalf("HandleWebhook() error = %v, want %v", err, ErrAmountMismatch) } if orders.markPaidN != 0 { t.Fatalf("MarkPaid called %d times, want 0 on mismatch", orders.markPaidN) } } func TestService_HandleWebhook_IgnoresPendingStatus(t *testing.T) { t.Parallel() orders := &FakeOrderRepository{total: 225000, exists: true} events := newFakeEvents() svc := NewService(orders, events) ev := WebhookEvent{EventID: "evt_3", OrderID: "ord_1", Status: "pending", GrossAmount: 225000} if err := svc.HandleWebhook(context.Background(), ev); err != nil { t.Fatalf("HandleWebhook() error = %v, want nil for pending", err) } if orders.markPaidN != 0 { t.Fatalf("MarkPaid called %d times, want 0 for non-final status", orders.markPaidN) } }
⚠️Verifikasi nominal itu bukan opsional

Tanpa cek GrossAmount terhadap total order, payload yang dirusak bisa menandai order mahal sebagai lunas dengan nominal kecil. Test RejectsAmountMismatch menjaga rule ini tetap ada.

08

Menguji Inventory: Reserve dan Release Stok

Inventory adalah tempat stok berpindah antara tersedia dan tertahan, jadi salah hitung di sini langsung jadi oversell.

Saat checkout, stok produk ditahan (reserve). Bila pembayaran gagal atau order dibatalkan, stok yang tertahan harus dikembalikan (release). Dua operasi ini cermin satu sama lain, dan di test kita pastikan keduanya menjaga invarian: stok tersedia tidak pernah negatif, dan release tidak pernah melebihi yang pernah direserve.

internal/inventory/service.go
package inventory import ( "context" "errors" ) var ( ErrSKUNotFound = errors.New("sku not found") ErrInsufficientStock = errors.New("insufficient available stock") ErrInvalidQuantity = errors.New("quantity must be positive") ErrReleaseTooLarge = errors.New("cannot release more than reserved") ) type Stock struct { SKU string Available int Reserved int } type StockRepository interface { Get(ctx context.Context, sku string) (Stock, error) Save(ctx context.Context, stock Stock) error } type Service struct { stocks StockRepository } func NewService(stocks StockRepository) *Service { return &Service{stocks: stocks} } func (s *Service) Reserve(ctx context.Context, sku string, qty int) error { if qty <= 0 { return ErrInvalidQuantity } stock, err := s.stocks.Get(ctx, sku) if err != nil { return err } if stock.Available < qty { return ErrInsufficientStock } stock.Available -= qty stock.Reserved += qty return s.stocks.Save(ctx, stock) } func (s *Service) Release(ctx context.Context, sku string, qty int) error { if qty <= 0 { return ErrInvalidQuantity } stock, err := s.stocks.Get(ctx, sku) if err != nil { return err } if stock.Reserved < qty { return ErrReleaseTooLarge } stock.Reserved -= qty stock.Available += qty return s.stocks.Save(ctx, stock) }

Test inventory memakai fake stock repository berbasis map, lalu mengecek nilai akhir Available dan Reserved setelah operasi. Helper kecil membaca state agar assertion ringkas.

internal/inventory/service_test.go
package inventory import ( "context" "errors" "testing" ) type FakeStockRepository struct { items map[string]Stock } func newFakeStock(seed ...Stock) *FakeStockRepository { items := make(map[string]Stock, len(seed)) for _, s := range seed { items[s.SKU] = s } return &FakeStockRepository{items: items} } func (r *FakeStockRepository) Get(ctx context.Context, sku string) (Stock, error) { stock, ok := r.items[sku] if !ok { return Stock{}, ErrSKUNotFound } return stock, nil } func (r *FakeStockRepository) Save(ctx context.Context, stock Stock) error { r.items[stock.SKU] = stock return nil } func TestService_Reserve_MovesAvailableToReserved(t *testing.T) { t.Parallel() repo := newFakeStock(Stock{SKU: "sku_toner", Available: 10, Reserved: 0}) svc := NewService(repo) if err := svc.Reserve(context.Background(), "sku_toner", 3); err != nil { t.Fatalf("Reserve() error = %v", err) } got := repo.items["sku_toner"] if got.Available != 7 || got.Reserved != 3 { t.Fatalf("after reserve = {available:%d reserved:%d}, want {7 3}", got.Available, got.Reserved) } } func TestService_Reserve_FailsWhenNotEnoughAvailable(t *testing.T) { t.Parallel() repo := newFakeStock(Stock{SKU: "sku_serum", Available: 2, Reserved: 0}) svc := NewService(repo) err := svc.Reserve(context.Background(), "sku_serum", 5) if !errors.Is(err, ErrInsufficientStock) { t.Fatalf("Reserve() error = %v, want %v", err, ErrInsufficientStock) } got := repo.items["sku_serum"] if got.Available != 2 || got.Reserved != 0 { t.Fatalf("stock must be unchanged on failure, got {%d %d}", got.Available, got.Reserved) } } func TestService_Release_ReturnsReservedToAvailable(t *testing.T) { t.Parallel() repo := newFakeStock(Stock{SKU: "sku_mask", Available: 4, Reserved: 6}) svc := NewService(repo) if err := svc.Release(context.Background(), "sku_mask", 6); err != nil { t.Fatalf("Release() error = %v", err) } got := repo.items["sku_mask"] if got.Available != 10 || got.Reserved != 0 { t.Fatalf("after release = {available:%d reserved:%d}, want {10 0}", got.Available, got.Reserved) } } func TestService_Release_FailsWhenReleasingMoreThanReserved(t *testing.T) { t.Parallel() repo := newFakeStock(Stock{SKU: "sku_mask", Available: 4, Reserved: 1}) svc := NewService(repo) err := svc.Release(context.Background(), "sku_mask", 3) if !errors.Is(err, ErrReleaseTooLarge) { t.Fatalf("Release() error = %v, want %v", err, ErrReleaseTooLarge) } }
💡Reserve lalu Release adalah round trip

Tes yang kuat sering memanggil Reserve kemudian Release dengan jumlah sama dan memastikan stok kembali ke nilai awal. Properti pulang pergi ini menangkap banyak bug aritmetika sekaligus.

09

Mock Manual vs testify/mock

Untuk service kecil, mock manual sering lebih mudah dibaca daripada mock framework.

Library github.com/stretchr/testify/mock (seri v1, jalur impor github.com/stretchr/testify) populer untuk membuat mock berbasis expectation. Ia berguna saat kamu perlu memverifikasi call tertentu, tetapi untuk domain logic seperti checkout, payment, dan inventory, fake repository manual sering lebih natural karena bisa menyimpan state.

Mock manual
  • Lebih eksplisit, cocok untuk fake repository berbasis map.
  • Bagus untuk menguji efek bisnis seperti stok berkurang dan order tersimpan.
  • Tidak menambah dependency test.
testify/mock
  • Cepat untuk expectation sederhana, misalnya method dipanggil dengan argumen tertentu.
  • Bisa membuat test terlalu fokus ke urutan call jika dipakai berlebihan.
  • Menambah dependency eksternal yang harus dipahami tim.
internal/checkout/product_mock_test.go
package checkout import ( "context" "github.com/stretchr/testify/mock" ) type MockProductRepository struct { mock.Mock } func (m *MockProductRepository) FindByID(ctx context.Context, id ProductID) (Product, error) { args := m.Called(ctx, id) product, _ := args.Get(0).(Product) return product, args.Error(1) } func (m *MockProductRepository) ReserveStock(ctx context.Context, id ProductID, quantity int) error { args := m.Called(ctx, id, quantity) return args.Error(0) }
💡Rekomendasi praktis

Mulai dari mock manual. Pakai testify/mock ketika jumlah interface besar, expectation kompleks, atau tim sudah sepakat dengan gaya tersebut.

10

Hands-on Ringan

Sekarang jalankan test, lalu sengaja rusak service untuk melihat apakah test benar-benar menjaga business rule.

Buat file service

Simpan kode service.go dan service_test.go untuk paket checkout, payment, dan inventory sesuai struktur modul.

Jalankan semua test

Gunakan go test ./... dari root project agar semua package ikut diperiksa.

Jalankan satu paket spesifik

Gunakan go test ./internal/payment -run TestService_HandleWebhook -v untuk fokus ke jalur webhook.

Uji idempotensi

Hapus pengecekan if processed di webhook, lalu jalankan test. MarkPaid akan terpanggil dua kali dan test idempotensi gagal.

Uji urutan voucher

Pindahkan ReserveStock sebelum validasi voucher, lalu jalankan test expired voucher. Test seharusnya gagal karena stok berubah.

Terminal
go test ./... go test ./internal/checkout -run TestService_Checkout_Success -v go test ./internal/payment -run TestService_HandleWebhook_MarksOrderPaidOnce -v go test ./internal/inventory -cover
🧪Analogi skincare shop

Fake repository seperti display sample di toko. Kamu bisa menguji proses rekomendasi, pembayaran kasir, dan tahan stok tanpa membuka gudang asli.

11

Jebakan Umum dari JS dan PHP

Pendatang dari JS dan PHP biasanya jatuh bukan karena sintaks test, tetapi karena desain dependency.

Interface terlalu besar

Jangan membuat Repository raksasa untuk semua domain. Service checkout hanya perlu method yang dipakai checkout.

Fake terlalu pintar

Fake sebaiknya cukup untuk test. Kalau fake mulai punya query engine sendiri, kamu sedang menulis database kecil di test.

Test terlalu tahu implementasi

Cek output bisnis dan state penting, bukan setiap private step service.

Waktu tidak dikontrol

Voucher expired harus memakai waktu deterministik dari request atau clock dependency, bukan selalu time.Now langsung di test.

Idempotensi dianggap urusan DB

Jangan menunda cek event ganda ke unique index. Angkat ke business logic agar bisa diuji tanpa database.

Membandingkan error pakai string

Pakai errors.Is, bukan cocokkan pesan, agar test tetap benar saat error dibungkus %w.

⚠️Jangan mock yang tidak perlu

Kalau fungsi murni bisa dites langsung, jangan dibuat interface hanya demi mock. Interface dipakai saat ada boundary nyata seperti database, network, file system, atau clock.

12

Ringkasan & Poin Penting

Service test memastikan aturan bisnis checkout, payment, dan inventory tetap benar meskipun implementasi repository berubah.

Yang Wajib Menempel

  • Service layer adalah tempat terbaik untuk menguji business rule seperti stok cukup, voucher valid, nominal pembayaran cocok, dan total order benar.
  • Interface kecil membuat dependency mudah diganti dengan fake repository saat test.
  • Fake repository berbasis map cocok untuk test domain karena bisa menyimpan state dan memverifikasi efek bisnis.
  • Checkout: happy path order berhasil, failure path stok kurang, edge case voucher expired tidak mengurangi stok.
  • Payment webhook: verifikasi status, cocokkan nominal, dan jaga idempotensi agar MarkPaid hanya sekali walau event berulang.
  • Inventory: reserve memindah available ke reserved, release mengembalikannya, dan keduanya menjaga invarian stok tidak negatif.
  • Selalu cek error dengan errors.Is; mock manual cukup untuk modul ini, testify/mock opsional.

Di proyek online shop skincare, pola ini dipakai untuk menguji service cart, checkout, inventory, payment webhook, voucher, dan order lifecycle. Chapter berikutnya naik ke integration test, yaitu memastikan repository pgx dan PostgreSQL benar-benar bekerja dengan skema database nyata.

Progress disimpan lokal di browser ini.