Progress belajar
Modul 49 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Unit Testing Dasar di Go
logic dulu, integrasi nanti
Mulai testing backend dari fungsi murni yang mudah dipercaya sebelum masuk ke HTTP handler, database, dan worker.
Kenapa Unit Test Dulu?
Roadmap 6 dimulai dari test yang tidak menyentuh database dan HTTP
Unit test adalah cara paling murah untuk membuktikan aturan bisnis berjalan benar, terutama aturan cart, voucher, dan stok.
Di React, kamu mungkin pernah menguji helper seperti formatPrice atau reducer tanpa mount komponen. Di Laravel, padanannya mirip menguji service class tanpa boot database. Di Go, titik awalnya sama: ambil logic yang tidak butuh jaringan, file, database, atau HTTP request, lalu uji dengan package testing bawaan.
Kalau di JS kamu suka memisahkan fungsi pure agar mudah dites, di Go kebiasaan itu bahkan lebih penting karena handler HTTP dan repository PostgreSQL sebaiknya tipis.
Pada backend online shop skincare, tiga aturan bisnis ini paling sering rusak diam-diam dan paling murah diuji sebagai fungsi murni: hitung diskon voucher, validasi stok, dan transisi status order (pending → paid → shipped). Modul ini fokus menuntaskan ketiganya. Test seperti ini cepat, stabil, dan ideal dijalankan setiap kali kamu menyimpan perubahan.
flowchart TD
Input["Input sederhana"] --> Logic["Pure Go logic"]
Logic --> Output["Output atau error"]
Logic -. tidak perlu .-> DB[("PostgreSQL")]
Logic -. tidak perlu .-> HTTP["HTTP request"]
Logic -. tidak perlu .-> Gateway["Payment gateway"]Gambar 1. Unit test di modul ini hanya menguji logic, bukan database, HTTP, atau gateway eksternal.
Roadmap 6 mengikuti bentuk test pyramid: banyak unit test yang murah dan cepat di bawah, sedikit handler dan integration test di tengah, sedikit e2e di puncak. Modul ini membangun dasar piramida itu, dan modul berikutnya naik selapis demi selapis.
flowchart TD E2E["E2E test<br/>sedikit, lambat, paling mahal"] --> INT["Integration & handler test<br/>httptest, fake repository, database test"] INT --> UNIT["Unit test pure logic<br/>banyak, milidetik, paling murah"]
Gambar 2. Test pyramid: modul ini mengisi lapisan paling bawah, fondasi yang menanggung sebagian besar kepercayaan terhadap aturan bisnis.
Test kecil yang memverifikasi satu unit logic, biasanya fungsi atau method, dengan input terkontrol dan output yang bisa diprediksi.
Package testing Bawaan Go
Tidak perlu Jest, PHPUnit, atau assertion library dulu
Go membawa package testing di standard library, dan command go test sudah tahu cara menemukan serta menjalankan test.
Dokumentasi resmi Go menjelaskan bahwa package testing dipakai bersama command go test, dan fungsi test berbentuk TestXxx dengan parameter *testing.T. Karena ini bawaan toolchain, kamu tidak perlu memasang library eksternal untuk memulai unit test.
Mulai dari standard library. Assertion library boleh dipakai nanti, tetapi dasar yang wajib kamu kuasai adalah testing.T, t.Run, t.Errorf, dan t.Fatalf.
Di banyak ekosistem JavaScript, test runner, assertion, dan mocking sering datang dari paket berbeda. Di Go, pengalaman awalnya lebih sederhana: file test berakhiran _test.go, function test diawali Test, lalu jalankan go test.
Test runner
go test adalah command resmi untuk compile dan menjalankan test.
Test file
File test diletakkan di package yang sama atau package eksternal dengan suffix _test.go.
Test function
Nama test diawali Test, lalu menerima parameter t *testing.T.
internal/cart/calculator_test.gopackage cart import "testing" func TestCalculateCartTotal(t *testing.T) { got := int64(20_000 + 35_000) want := int64(55_000) if got != want { t.Errorf("got %d, want %d", got, want) } }
Anatomi Test Function
Nama, parameter, arrange, act, assert
Bentuk dasar test Go sengaja sederhana agar fokusnya ada di behavior, bukan framework.
Nama test mengikuti pola TestNamaFungsi. Parameter t *testing.T adalah handle untuk melaporkan kegagalan, membuat subtest, menulis log, dan menghentikan test saat kondisi fatal.
Siapkan input: item cart, voucher, stok, atau status order.
Panggil fungsi yang diuji, misalnya CalculateCartTotal.
Bandingkan hasil aktual dengan hasil yang diharapkan, lalu panggil t.Errorf atau t.Fatalf bila salah.
internal/promotion/voucher_anatomy_test.gofunc TestApplyVoucherAnatomy(t *testing.T) { // arrange now := time.Date(2026, 6, 6, 10, 0, 0, 0, time.UTC) subtotal := int64(100_000) voucher := Voucher{Code: "GLOW25", Type: DiscountFixed, Value: 25_000, ExpiresAt: now.Add(24 * time.Hour)} // act got, err := ApplyVoucher(now, subtotal, voucher) // assert if err != nil { t.Fatalf("ApplyVoucher returned error: %v", err) } if got != 75_000 { t.Errorf("got %d, want %d", got, int64(75_000)) } }
Komentar arrange, act, assert tidak wajib di setiap test, tetapi berguna saat kamu sedang belajar atau saat skenario bisnisnya kompleks.
t.Errorf vs t.Fatalf
Kapan lanjut, kapan berhenti
t.Errorf menandai test gagal tetapi eksekusi lanjut, sedangkan t.Fatalf menandai gagal dan langsung menghentikan test saat itu.
Gunakan t.Fatalf saat kondisi berikutnya tidak aman diperiksa. Contohnya, kalau fungsi mengembalikan error padahal harus sukses, hasil output mungkin tidak valid untuk dibandingkan. Gunakan t.Errorf saat kamu masih bisa mengumpulkan beberapa perbedaan sekaligus.
- Jest sering memakai
expectyang langsung memberi laporan assertion. - PHPUnit juga punya method assertion seperti
assertSamedanassertTrue.
- Go standard library memakai logika biasa:
if got != want, lalu laporkan lewatt.Errorfataut.Fatalf. - Perbandingan eksplisit membuat test mudah dibaca tanpa DSL tambahan.
internal/cart/errorf_vs_fatalf_test.gofunc TestErrorfVsFatalf(t *testing.T) { got, err := CalculateCartTotal([]CartItem{{SKU: "TONER-100", Quantity: 2, UnitPrice: 35_000}}) if err != nil { t.Fatalf("CalculateCartTotal returned error: %v", err) } want := int64(70_000) if got != want { t.Errorf("got %d, want %d", got, want) } }
Jangan lanjut membandingkan field dari hasil yang belum valid. Kalau setup gagal atau error tidak sesuai harapan, pakai t.Fatalf.
Table-driven Test & Subtest
Idiom paling umum untuk banyak skenario dalam satu test
Table-driven test adalah pola Go untuk menaruh daftar skenario dalam slice struct, lalu menjalankannya dengan loop dan t.Run.
Di Jest, kamu mungkin memakai describe, it, atau test.each. Di Go, pola idiomatiknya adalah slice struct anonim berisi nama kasus, input, expected output, dan expected error. Pola ini membuat test ringkas tanpa kehilangan konteks bisnis.
- Skenario sering dipisah menjadi banyak blok
it. test.eachbisa dipakai untuk data-driven test.- Assertion biasanya memakai
expect.
- Skenario ditulis sebagai data dalam
tests. - Setiap baris dijalankan sebagai subtest dengan
t.Run. - Assertion memakai
ifbiasa dantesting.T.
internal/cart/table_shape_test.gofunc TestExampleTableShape(t *testing.T) { tests := []struct { name string input int want int }{ {name: "double positive", input: 2, want: 4}, {name: "double zero", input: 0, want: 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := tc.input * 2 if got != tc.want { t.Errorf("got %d, want %d", got, tc.want) } }) } }
Materi lama sering menulis tc := tc di dalam loop sebelum t.Run. Sejak Go 1.22, loop variable dibuat per iterasi, jadi pola ini tidak lagi diperlukan untuk Go 1.26 kecuali kamu harus mendukung versi lama.
Setiap t.Run menciptakan subtest dengan nama sendiri, sehingga output go test -v jadi rapi dan kamu bisa menjalankan satu kasus saja, misalnya go test -run TestExampleTableShape/double_zero. Spasi pada nama kasus otomatis diganti garis bawah pada nama subtest.
Kalau fungsi yang diuji benar-benar murni (tidak berbagi state), kamu boleh menjalankan subtest secara paralel dengan memanggil t.Parallel() di awal closure. Ini mempercepat suite besar, tetapi hanya aman untuk fungsi tanpa efek samping bersama, persis tipe fungsi yang kita uji di modul ini.
internal/cart/table_parallel_test.gofor _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() // aman karena fungsi murni, tanpa state bersama got := tc.input * 2 if got != tc.want { t.Errorf("got %d, want %d", got, tc.want) } }) }
Subtest t.Run adalah padanan blok it di dalam describe pada Jest, sedangkan tabel skenarionya menggantikan test.each. Bedanya, di Go semuanya data biasa plus loop, bukan API runner khusus.
Contoh: CalculateCartTotal
Menguji total cart tanpa database
Cart total adalah contoh unit test ideal karena input dan outputnya jelas, tanpa perlu mengambil harga dari PostgreSQL.
Pada domain cart, total dihitung dari quantity * unit price. Dalam sistem nyata, harga item biasanya berasal dari product variant saat response cart dibangun. Tetapi fungsi hitung total tetap bisa dibuat murni agar mudah diuji.
internal/cart/calculator.gopackage cart import "errors" var ( ErrEmptyCart = errors.New("cart is empty") ErrInvalidQuantity = errors.New("cart item quantity must be positive") ErrInvalidPrice = errors.New("cart item price must be positive") ) type CartItem struct { SKU string Quantity int UnitPrice int64 } func CalculateCartTotal(items []CartItem) (int64, error) { if len(items) == 0 { return 0, ErrEmptyCart } var total int64 for _, item := range items { if item.Quantity <= 0 { return 0, ErrInvalidQuantity } if item.UnitPrice <= 0 { return 0, ErrInvalidPrice } total += int64(item.Quantity) * item.UnitPrice } return total, nil }
internal/cart/calculator_test.gopackage cart import ( "errors" "testing" ) func TestCalculateCartTotal(t *testing.T) { tests := []struct { name string items []CartItem want int64 wantErr error }{ { name: "single item", items: []CartItem{ {SKU: "TONER-100", Quantity: 2, UnitPrice: 35_000}, }, want: 70_000, }, { name: "multiple items", items: []CartItem{ {SKU: "TONER-100", Quantity: 2, UnitPrice: 35_000}, {SKU: "SERUM-30", Quantity: 1, UnitPrice: 120_000}, }, want: 190_000, }, { name: "empty cart", items: []CartItem{}, wantErr: ErrEmptyCart, }, { name: "invalid quantity", items: []CartItem{ {SKU: "TONER-100", Quantity: 0, UnitPrice: 35_000}, }, wantErr: ErrInvalidQuantity, }, { name: "invalid price", items: []CartItem{ {SKU: "SERUM-30", Quantity: 1, UnitPrice: 0}, }, wantErr: ErrInvalidPrice, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := CalculateCartTotal(tc.items) if !errors.Is(err, tc.wantErr) { t.Fatalf("error = %v, want %v", err, tc.wantErr) } if got != tc.want { t.Errorf("total = %d, want %d", got, tc.want) } }) } }
Satu fungsi test bisa menutup happy path dan edge case sekaligus, sehingga reviewer langsung melihat aturan bisnis cart dalam satu tempat.
Contoh: ApplyVoucher
Menguji diskon tanpa checkout flow lengkap
Voucher punya banyak aturan bisnis, jadi table-driven test membantu mencegah diskon bocor atau bisa dieksploitasi.
Di proyek skincare, voucher tidak boleh hanya diuji dari endpoint checkout. Logic diskon harus punya test sendiri agar bug seperti minimum purchase terlewat, voucher expired masih berlaku, atau fixed discount melebihi subtotal bisa ditangkap cepat.
internal/promotion/voucher.gopackage promotion import ( "errors" "time" ) var ( ErrInvalidSubtotal = errors.New("subtotal must be positive") ErrVoucherExpired = errors.New("voucher expired") ErrMinimumPurchase = errors.New("minimum purchase not met") ErrUsageLimit = errors.New("voucher usage limit reached") ErrUserLimit = errors.New("voucher user limit reached") ErrInvalidDiscount = errors.New("invalid discount") ) type DiscountType string const ( DiscountFixed DiscountType = "fixed" DiscountPercentage DiscountType = "percentage" ) type Voucher struct { Code string Type DiscountType Value int64 MinimumSubtotal int64 UsageLimit int UsedCount int PerUserLimit int UserUsedCount int ExpiresAt time.Time } func ApplyVoucher(now time.Time, subtotal int64, voucher Voucher) (int64, error) { if subtotal <= 0 { return 0, ErrInvalidSubtotal } if !voucher.ExpiresAt.IsZero() && now.After(voucher.ExpiresAt) { return 0, ErrVoucherExpired } if subtotal < voucher.MinimumSubtotal { return 0, ErrMinimumPurchase } if voucher.UsageLimit > 0 && voucher.UsedCount >= voucher.UsageLimit { return 0, ErrUsageLimit } if voucher.PerUserLimit > 0 && voucher.UserUsedCount >= voucher.PerUserLimit { return 0, ErrUserLimit } discount, err := calculateDiscount(subtotal, voucher) if err != nil { return 0, err } if discount > subtotal { discount = subtotal } return subtotal - discount, nil } func calculateDiscount(subtotal int64, voucher Voucher) (int64, error) { switch voucher.Type { case DiscountFixed: if voucher.Value <= 0 { return 0, ErrInvalidDiscount } return voucher.Value, nil case DiscountPercentage: if voucher.Value <= 0 || voucher.Value > 100 { return 0, ErrInvalidDiscount } return subtotal * voucher.Value / 100, nil default: return 0, ErrInvalidDiscount } }
internal/promotion/voucher_test.gopackage promotion import ( "errors" "testing" "time" ) func TestApplyVoucher(t *testing.T) { now := time.Date(2026, 6, 6, 10, 0, 0, 0, time.UTC) tests := []struct { name string subtotal int64 voucher Voucher want int64 wantErr error }{ { name: "fixed discount", subtotal: 150_000, voucher: Voucher{Code: "GLOW25", Type: DiscountFixed, Value: 25_000, ExpiresAt: now.Add(24 * time.Hour)}, want: 125_000, }, { name: "percentage discount", subtotal: 200_000, voucher: Voucher{Code: "SERUM10", Type: DiscountPercentage, Value: 10, ExpiresAt: now.Add(24 * time.Hour)}, want: 180_000, }, { name: "minimum purchase not met", subtotal: 80_000, voucher: Voucher{Code: "MIN100", Type: DiscountFixed, Value: 10_000, MinimumSubtotal: 100_000, ExpiresAt: now.Add(24 * time.Hour)}, wantErr: ErrMinimumPurchase, }, { name: "expired voucher", subtotal: 150_000, voucher: Voucher{Code: "OLD", Type: DiscountFixed, Value: 10_000, ExpiresAt: now.Add(-24 * time.Hour)}, wantErr: ErrVoucherExpired, }, { name: "usage limit reached", subtotal: 150_000, voucher: Voucher{Code: "FULL", Type: DiscountFixed, Value: 10_000, UsageLimit: 100, UsedCount: 100, ExpiresAt: now.Add(24 * time.Hour)}, wantErr: ErrUsageLimit, }, { name: "discount capped by subtotal", subtotal: 20_000, voucher: Voucher{Code: "BIG", Type: DiscountFixed, Value: 50_000, ExpiresAt: now.Add(24 * time.Hour)}, want: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := ApplyVoucher(now, tc.subtotal, tc.voucher) if !errors.Is(err, tc.wantErr) { t.Fatalf("error = %v, want %v", err, tc.wantErr) } if got != tc.want { t.Errorf("final subtotal = %d, want %d", got, tc.want) } }) } }
Jangan menguji voucher hanya dari checkout endpoint. Kalau semua rule diskon tersembunyi di handler, bug akan mahal dicari saat integrasi payment dan inventory ikut terlibat.
Contoh: ValidateStock
Menguji stok tersedia dan safety stock
Validasi stok adalah business rule yang harus bisa diuji cepat sebelum kita menulis transaksi database dan row locking.
Pada Roadmap 5, inventory punya available_stock, reserved_stock, sold_stock, dan safety stock. Di modul ini kita mulai dari validasi kecil: apakah variant aktif dan jumlah yang diminta masih berada di atas safety stock.
internal/inventory/stock.gopackage inventory import "errors" var ( ErrInvalidRequestedQuantity = errors.New("requested quantity must be positive") ErrProductInactive = errors.New("product variant inactive") ErrInsufficientStock = errors.New("insufficient stock") ) type ProductVariant struct { SKU string IsActive bool AvailableStock int SafetyStock int } func ValidateStock(variant ProductVariant, requestedQty int) error { if requestedQty <= 0 { return ErrInvalidRequestedQuantity } if !variant.IsActive { return ErrProductInactive } sellableStock := variant.AvailableStock - variant.SafetyStock if requestedQty > sellableStock { return ErrInsufficientStock } return nil }
internal/inventory/stock_test.gopackage inventory import ( "errors" "testing" ) func TestValidateStock(t *testing.T) { tests := []struct { name string variant ProductVariant requestedQty int wantErr error }{ { name: "stock available", variant: ProductVariant{SKU: "TONER-100", IsActive: true, AvailableStock: 10, SafetyStock: 2}, requestedQty: 3, }, { name: "requested quantity must be positive", variant: ProductVariant{SKU: "TONER-100", IsActive: true, AvailableStock: 10, SafetyStock: 2}, requestedQty: 0, wantErr: ErrInvalidRequestedQuantity, }, { name: "inactive variant", variant: ProductVariant{SKU: "SERUM-30", IsActive: false, AvailableStock: 10, SafetyStock: 2}, requestedQty: 1, wantErr: ErrProductInactive, }, { name: "safety stock protected", variant: ProductVariant{SKU: "CREAM-50", IsActive: true, AvailableStock: 5, SafetyStock: 2}, requestedQty: 4, wantErr: ErrInsufficientStock, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := ValidateStock(tc.variant, tc.requestedQty) if !errors.Is(err, tc.wantErr) { t.Fatalf("error = %v, want %v", err, tc.wantErr) } }) } }
Di Laravel, validasi request bisa memakai Form Request, tetapi rule seperti stok cukup sebaiknya tetap ada di service/domain. Di Go, pemisahan ini terlihat jelas karena unit test memanggil fungsi domain langsung.
Contoh: Transisi Status Order
State machine sebagai pure function yang sangat layak diuji
Status order bergerak lewat jalur yang ketat, dan satu transisi salah bisa berarti barang dikirim sebelum dibayar atau order yang sudah selesai dibatalkan.
Status order adalah state machine: dari pending hanya boleh ke paid atau cancelled, dari paid ke shipped atau refunded, dan seterusnya. Aturan ini gampang dilanggar kalau tersebar di banyak handler. Kumpulkan ke satu fungsi murni CanTransition, lalu uji seluruh matriks transisinya dengan table-driven test.
stateDiagram-v2 [*] --> pending pending --> paid: payment success pending --> cancelled: user batal / timeout paid --> shipped: gudang kirim paid --> refunded: refund disetujui shipped --> completed: diterima pembeli cancelled --> [*] refunded --> [*] completed --> [*]
Gambar 3. Lifecycle status order. Hanya panah pada diagram inilah transisi yang sah, semua kombinasi lain harus ditolak.
internal/order/status.gopackage order import "errors" var ErrInvalidTransition = errors.New("invalid order status transition") type Status string const ( StatusPending Status = "pending" StatusPaid Status = "paid" StatusShipped Status = "shipped" StatusCompleted Status = "completed" StatusCancelled Status = "cancelled" StatusRefunded Status = "refunded" ) // allowed memetakan status saat ini ke himpunan status tujuan yang sah. var allowed = map[Status]map[Status]bool{ StatusPending: {StatusPaid: true, StatusCancelled: true}, StatusPaid: {StatusShipped: true, StatusRefunded: true}, StatusShipped: {StatusCompleted: true}, StatusCompleted: {}, StatusCancelled: {}, StatusRefunded: {}, } // CanTransition mengembalikan nil bila perpindahan from -> to diizinkan. func CanTransition(from, to Status) error { if !allowed[from][to] { return ErrInvalidTransition } return nil }
internal/order/status_test.gopackage order import ( "errors" "testing" ) func TestCanTransition(t *testing.T) { tests := []struct { name string from Status to Status wantErr error }{ {name: "pending to paid", from: StatusPending, to: StatusPaid}, {name: "paid to shipped", from: StatusPaid, to: StatusShipped}, {name: "shipped to completed", from: StatusShipped, to: StatusCompleted}, {name: "pending to cancelled", from: StatusPending, to: StatusCancelled}, {name: "ship before paid", from: StatusPending, to: StatusShipped, wantErr: ErrInvalidTransition}, {name: "cancel after completed", from: StatusCompleted, to: StatusCancelled, wantErr: ErrInvalidTransition}, {name: "refund a shipped order", from: StatusShipped, to: StatusRefunded, wantErr: ErrInvalidTransition}, {name: "reopen cancelled order", from: StatusCancelled, to: StatusPaid, wantErr: ErrInvalidTransition}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := CanTransition(tc.from, tc.to) if !errors.Is(err, tc.wantErr) { t.Fatalf("CanTransition(%q, %q) error = %v, want %v", tc.from, tc.to, err, tc.wantErr) } }) } }
Kalau di React kamu pernah menjaga transisi state lewat reducer atau XState, ini padanannya di domain backend. Bedanya, di Go state machine cukup berupa map plus satu fungsi murni, dan testnya menutup seluruh sel matriks transisi.
Jangan menaruh aturan transisi di handler update order. Kalau tersebar, mudah muncul jalur ilegal seperti shipped langsung jadi pending, dan baru ketahuan saat order nyangkut di produksi.
Menjalankan Test & Race Detector
Command harian yang wajib hafal
Setelah file test dibuat, workflow utamanya adalah menjalankan test package tertentu, seluruh module, atau satu test spesifik.
go test ./... menjalankan semua package dalam module saat ini. go test -v menampilkan output verbose, termasuk nama subtest. go test -run TestNamaSpesifik hanya menjalankan test yang cocok dengan pola nama tersebut.
Terminalgo test ./... go test ./internal/cart go test ./internal/cart -v go test ./... -run TestCalculateCartTotal go test ./... -run TestApplyVoucher
Pola ./... berarti semua package di bawah direktori saat ini, sehingga cocok dipakai dari root project saat menjalankan test lokal atau CI.
Pakai go test ./internal/promotion -run TestApplyVoucher -v saat sedang memperbaiki satu fungsi voucher agar feedback tetap cepat.
Contoh output=== RUN TestCalculateCartTotal === RUN TestCalculateCartTotal/single_item === RUN TestCalculateCartTotal/multiple_items === RUN TestCalculateCartTotal/empty_cart --- PASS: TestCalculateCartTotal (0.00s) --- PASS: TestCalculateCartTotal/single_item (0.00s) --- PASS: TestCalculateCartTotal/multiple_items (0.00s) --- PASS: TestCalculateCartTotal/empty_cart (0.00s) PASS ok github.com/kamu/skincare-backend/internal/cart 0.003s
Race detector
Flag -race mengaktifkan race detector bawaan Go, yang melaporkan akses memori bersamaan tanpa sinkronisasi.
Untuk fungsi murni di modul ini, -race jarang menemukan apa-apa, dan itu memang yang kita harapkan. Tetapi biasakan menjalankannya sejak awal: begitu kode mulai memakai goroutine (worker inventory, pengiriman email, webhook payment), -race menjadi penjaga paling murah terhadap bug konkurensi yang sulit direproduksi.
Terminalgo test ./... -race go test ./internal/order -run TestCanTransition -race -v
Untuk setup sekali per package (misalnya seed data uji), Go punya func TestMain(m *testing.M). Untuk mengukur performa fungsi panas, ada func BenchmarkXxx(b *testing.B) yang dijalankan dengan go test -bench. Keduanya di luar fokus modul ini, cukup tahu bahwa keduanya ada.
Test Coverage
Angka coverage adalah sinyal, bukan tujuan akhir
Coverage membantu melihat bagian kode yang belum tersentuh test, tetapi coverage tinggi tidak otomatis berarti business rule sudah benar.
Gunakan go test -cover untuk ringkasan cepat. Jika butuh laporan HTML, buat profile dengan -coverprofile, lalu buka dengan go tool cover -html. Ini berguna saat kamu ingin melihat cabang logic voucher mana yang belum diuji.
Terminalgo test ./... -cover go test ./... -coverprofile=coverage.out go tool cover -html=coverage.out
Contoh outputok github.com/kamu/skincare-backend/internal/cart 0.003s coverage: 94.1% of statements ok github.com/kamu/skincare-backend/internal/promotion 0.004s coverage: 88.5% of statements ok github.com/kamu/skincare-backend/internal/inventory 0.003s coverage: 91.7% of statements
Coverage 100 persen bisa tetap salah kalau skenario bisnis penting tidak ditulis. Fokus pada risk: uang, stok, status order, payment, dan keamanan.
Coverage bagus
Menjawab pertanyaan: baris mana yang sudah pernah dieksekusi test?
Coverage belum cukup
Tidak menjawab apakah assertion sudah kuat dan semua edge case bisnis sudah masuk.
Struktur File Test
Dekatkan test dengan package yang diuji
Untuk unit test dasar, letakkan file _test.go berdampingan dengan file production di package yang sama.
Struktur ini membuat test mudah ditemukan dan menjaga package tetap kecil. Saat nanti masuk handler test, repository integration test, dan e2e test, kita bisa menambah folder khusus bila dibutuhkan.
- internal/
- cart/
- calculator.go pure cart logic
- calculator_test.go unit test cart
- promotion/
- voucher.go pure voucher logic
- voucher_test.go unit test voucher
- inventory/
- stock.go pure stock validation
- stock_test.go unit test stock
- order/
- status.go pure status transition rule
- status_test.go unit test transisi status
- go.mod
Pakai package yang sama untuk unit test internal logic. Package eksternal seperti cart_test cocok saat kamu ingin menguji hanya API publik package tersebut.
Jebakan Umum
Kesalahan yang sering dibawa dari JS, PHP, dan test framework besar
Unit test Go terlihat sederhana, tetapi ada beberapa kebiasaan yang bisa membuat test lambat, rapuh, atau sulit dipercaya.
Terlalu cepat menyentuh database
Untuk logic cart total, voucher, dan stock validation, database hanya membuat test lambat dan rentan setup.
Test hanya happy path
Business rule penting biasanya rusak di edge case: cart kosong, stok safety, voucher expired, dan batas diskon.
Assertion kurang jelas
Pesan error seperti failed tidak membantu. Tulis got dan want agar debugging cepat.
Menyembunyikan logic di handler
Handler yang terlalu gemuk sulit diunit test. Pindahkan rule ke fungsi atau service murni.
Jangan mencari describe sebagai struktur wajib. Di Go, t.Run dan table-driven test sudah cukup untuk mengelompokkan skenario dengan jelas.
Jangan menganggap semua test harus boot aplikasi dan database. Unit test Go yang baik sering kali hanya compile package, panggil fungsi, lalu selesai dalam milidetik.
Hands-on Ringan
Latihan 30 sampai 45 menit sebelum masuk handler test
Latihan ini membuat fondasi testing kamu siap sebelum modul berikutnya menguji HTTP handler dan service dengan dependency palsu.
Buat internal/cart, internal/promotion, internal/inventory, dan internal/order sesuai contoh di modul ini.
Mulai dari CalculateCartTotal, ApplyVoucher, ValidateStock, dan CanTransition tanpa database dan HTTP.
Minimal tiga skenario per fungsi: satu happy path dan dua edge case. Untuk transisi status, tutup minimal dua transisi sah dan dua transisi ilegal.
Pakai go test ./..., lalu ulang dengan go test ./... -v untuk melihat nama subtest, dan go test ./... -race sebagai kebiasaan.
Pakai go test ./... -cover, lalu tambah case bila cabang bisnis penting belum teruji.
Terminalgo test ./... go test ./... -v go test ./... -run TestCanTransition go test ./... -race go test ./... -cover
Setelah hands-on ini, kamu harus bisa menambah satu rule bisnis baru dan langsung menulis testnya tanpa menunggu endpoint atau database siap.
Ringkasan & Poin Penting
Unit testing dasar adalah fondasi Roadmap 6: kita belajar memastikan logic domain benar sebelum menambah HTTP, database, mock, dan integrasi.
Yang Wajib Menempel
- Go menyediakan package
testingdan commandgo testsebagai tool utama unit test, tanpa library eksternal. - Nama test mengikuti pola
TestNamaFungsidan menerima parametert *testing.T. t.Errorfmelaporkan gagal lalu lanjut, sedangkant.Fatalfmelaporkan gagal lalu berhenti.- Table-driven test plus subtest
t.Runadalah idiom utama Go untuk banyak skenario bisnis dalam satu fungsi test, dant.Parallelmempercepat fungsi murni. go test ./...,go test -v,go test -run,go test -race, dango test -coveradalah command harian yang wajib hafal.- Tiga aturan bisnis inti sudah punya test sendiri di modul ini: diskon voucher (
ApplyVoucher), validasi stok (ValidateStock), dan transisi status order (CanTransition). - Transisi status order paling rapi diuji sebagai state machine murni: satu map plus satu fungsi, lalu tutup transisi sah dan ilegal.
- Langkah berikutnya di Roadmap 6 adalah menguji handler HTTP dengan
net/http/httptest, lalu service dengan fake repository.
Mulai dari pure function membuat domain lebih bersih. Saat checkout nanti menyentuh PostgreSQL, payment gateway, dan inventory transaction, kamu sudah punya lapisan rule bisnis yang dipercaya.
Progress disimpan lokal di browser ini.