Go Error Handling dan Testing
Definisi
Di Go, kualitas project sering kelihatan dari empat hal yang sangat dasar tetapi sangat menentukan:
- error handling: bagaimana fungsi mengembalikan kegagalan secara eksplisit
- packages: bagaimana kode dipisah supaya dependency antarbagian tetap sehat
- testing: bagaimana perilaku diverifikasi dengan test yang mudah dibaca dan mudah di-maintain
- project structure: bagaimana folder dan boundary disusun supaya project tetap tumbuh tanpa jadi berantakan
Topik ini penting karena Go sengaja tidak menyembunyikan banyak hal di balik abstraksi berat. Struktur codebase yang rapi, error yang jelas, dan test yang konsisten akan langsung terasa dampaknya di debugging harian.
Prinsip utama
Go biasanya mendorong gaya kerja yang sederhana:
- fungsi mengembalikan hasil dan
error - pemanggil memeriksa
errorsegera - error yang ditambah konteks tetap bisa ditelusuri
- package hanya bertanggung jawab pada domain yang jelas
- test ditulis sebagai spesifikasi perilaku, bukan sekadar eksekusi kode
Kalau pola ini konsisten, codebase jauh lebih mudah dirawat daripada project yang terlalu cepat membesar tapi boundary-nya kabur.
Error return: idiom dasar di Go
Di Go, kegagalan biasanya tidak dilempar sebagai exception. Pola yang umum adalah mengembalikan error sebagai nilai kedua.
func LoadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
return cfg, nil
}Kenapa pola ini dipakai
- alur kegagalan terlihat eksplisit di setiap pemanggilan
- caller dipaksa memutuskan langkah berikutnya
- control flow lebih mudah dibaca saat debugging
- error handling tidak tersembunyi di balik mekanisme global
Pola yang sehat
value, err := DoSomething()
if err != nil {
return err
}Aturan praktisnya sederhana: cek error sesegera mungkin. Jangan menunda terlalu jauh karena jarak antara sumber error dan penanganannya akan membuat debugging lebih sulit.
Jebakan umum
- error diabaikan karena dianggap kecil
- fungsi terlalu panjang sehingga error check menumpuk tanpa struktur
- caller tidak tahu apakah error harus di-retry, di-log, atau langsung dibawa ke user
Kalau fungsi mulai terlalu ramai, pecah jadi langkah yang lebih kecil supaya error handling tetap lokal dan jelas.
Wrapping error: menambah konteks tanpa kehilangan asal masalah
Saat error naik ke layer yang lebih atas, biasanya error perlu diberi konteks baru. Di Go modern, ini dilakukan dengan wrapping.
user, err := repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("load user %s: %w", id, err)
}Kenapa wrapping penting
Error dari layer bawah sering terlalu teknis untuk langsung dipakai di layer atas. Wrapping membantu dua hal sekaligus:
- menambahkan konteks yang relevan untuk caller
- mempertahankan sumber error asli agar masih bisa ditelusuri dengan
errors.Isatauerrors.As
Contoh pengecekan error yang dibungkus
if errors.Is(err, os.ErrNotExist) {
// file memang tidak ada
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// ambil detail dari tipe error asli
}Aturan praktis wrapping
- tambahkan konteks di boundary antar-layer
- jangan wrap berulang kali tanpa alasan
- gunakan
%wkalau error asli masih perlu diinspeksi - gunakan message yang menjelaskan operasi yang gagal, bukan sekadar berkata "failed"
Contoh konteks yang bagus:
load user 42parse config file config.jsonsave invoice to database
Contoh yang terlalu samar:
failedsomething went wrongerror happened
Kapan tidak perlu wrap
Kalau error memang sudah cukup jelas dan tidak ada konteks tambahan yang berguna, return error apa adanya masih valid. Yang penting adalah jangan kehilangan informasi yang dibutuhkan untuk diagnosis.
Flow error dari layer bawah ke layer atas
Alur ini menjaga dua hal: pesan error tetap manusiawi, tetapi jejak asal masalah tidak hilang.
Packages: boundary yang sehat
Package di Go sebaiknya diperlakukan sebagai boundary yang nyata, bukan hanya folder tempat menaruh file.
Apa yang harus dijaga
- package punya tanggung jawab yang jelas
- dependency mengalir ke arah yang masuk akal
- package tidak saling bergantung secara silang tanpa alasan
- API package cukup kecil untuk dipahami dari luar
Prinsip package boundary
-
ekspos seperlunya
- identifier yang tidak perlu dipakai dari luar sebaiknya tetap unexported
- pakai huruf kecil untuk detail internal
-
jangan taruh terlalu banyak peran dalam satu package
- domain logic, transport, dan persistence sebaiknya tidak bercampur tanpa alasan
-
hindari package yang terlalu generik
- nama seperti
utils,helpers, ataucommonsering jadi tempat buangan kalau tidak dipakai dengan disiplin
- nama seperti
-
jadikan package sebagai unit tanggung jawab
- kalau suatu package sulit dijelaskan dalam satu kalimat, kemungkinan boundary-nya kurang jelas
Contoh struktur boundary yang wajar
internal/useruntuk logika userinternal/userrepoatauinternal/storeuntuk akses datacmd/apiuntuk entrypoint HTTPpkg/hanya untuk API yang memang ingin dipakai project lain
Kapan memakai internal
Pakai internal kalau package hanya boleh dipakai oleh code dalam repository yang sama. Ini bagus untuk menjaga boundary agar tidak terlalu cepat menjadi publik.
project/
├── cmd/
├── internal/
│ ├── app/
│ ├── domain/
│ ├── repository/
│ └── transport/
└── pkg/Kebiasaan yang sebaiknya dihindari
- import silang antarpackage yang membentuk lingkaran
- package yang hanya berisi satu dua fungsi tanpa alasan pemisahan
- file yang semuanya masuk ke package
main - mengekspor terlalu banyak symbol hanya karena "siapa tahu dipakai"
Testing: table-driven tests sebagai default
Di Go, table-driven test adalah pola yang sangat umum karena ringkas, jelas, dan mudah diperluas.
Bentuk dasarnya
func TestNormalizeName(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "trim spaces", input: " andi ", want: "Andi"},
{name: "empty", input: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NormalizeName(tt.input)
if got != tt.want {
t.Fatalf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}Kenapa table-driven test cocok untuk Go
- mudah menambah case baru tanpa menulis test baru dari nol
- input dan expected output terlihat berdampingan
- lebih enak dipakai untuk fungsi yang punya banyak variasi perilaku
t.Runmembuat output test lebih spesifik saat gagal
Pola yang sehat
- satu table untuk satu perilaku utama
- nama case harus deskriptif
- setiap case mewakili satu skenario, bukan campuran beberapa aturan
- assert gagal harus memberi pesan yang membantu
Kapan memakai subtest
Subtest sangat berguna saat:
- banyak skenario yang ingin dipisahkan di output test
- ada setup yang sama untuk beberapa case
- ingin menjalankan sebagian skenario dengan lebih jelas
Tabel test untuk error handling
Error handling juga sangat cocok dites dengan table-driven style.
func TestLoadConfig(t *testing.T) {
tests := []struct {
name string
path string
wantErr bool
}{
{name: "file exists", path: "testdata/config.json", wantErr: false},
{name: "missing file", path: "testdata/missing.json", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadConfig(tt.path)
if (err != nil) != tt.wantErr {
t.Fatalf("LoadConfig(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
}
})
}
}Jebakan umum test
- test terlalu bergantung pada state global
- test menggunakan data yang tidak jelas asalnya
- assertion terlalu banyak dalam satu case
- nama test tidak menjelaskan perilaku
- test lebih mirip script manual daripada verifikasi perilaku
Susunan folder yang sehat
Project structure yang sehat bukan soal mengikuti satu format baku untuk semua kasus. Yang penting adalah boundary-nya masuk akal dan mudah dipahami saat project tumbuh.
Struktur yang sering aman untuk aplikasi Go
project/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── app/
│ ├── config/
│ ├── domain/
│ ├── repository/
│ ├── service/
│ └── transport/
├── pkg/
├── testdata/
├── scripts/
├── go.mod
└── go.sumFungsi tiap folder
- cmd/: entrypoint aplikasi yang dijalankan sebagai binary
- internal/: kode inti yang tidak ingin dibuka sebagai API publik
- pkg/: library yang memang ingin dipakai lintas project, kalau benar-benar perlu
- testdata/: fixture dan file pendukung test
- scripts/: helper script build, deploy, atau maintenance
Cara berpikir saat menaruh code
- kalau ini hanya untuk menjalankan aplikasi, taruh di
cmd/ - kalau ini inti bisnis aplikasi, taruh di
internal/ - kalau ini memang reusable dan stabil, baru pertimbangkan
pkg/ - kalau ini hanya data test, taruh di
testdata/
Boundary yang biasanya sehat
Urutan dependency yang sering nyaman dibaca:
- transport / handler
- service / use case
- repository / storage
- external client / infrastructure
Dengan urutan ini, logic inti tidak terlalu bergantung pada detail transport atau storage.
Tanda struktur mulai tidak sehat
- folder
utilsmembesar tanpa definisi tanggung jawab main.goterlalu tebal- package saling impor bolak-balik
- detail database bocor ke layer handler
- test dan code produksi bercampur tanpa batas yang jelas
Contoh cara membagi tanggung jawab
Misalnya ada fitur create user:
transport/httpmenerima request dan parse payloadservice/usermemvalidasi aturan bisnis dan orchestrationrepository/usermenyimpan data ke databaseinternal/domainmemuat tipe dan rule inti kalau memang diperlukan
Pembagian seperti ini membuat error handling, testing, dan boundary package lebih mudah dirawat karena tiap layer punya tugas yang spesifik.
Catatan implementasi
Untuk error handling
- return
errorsebagai nilai biasa, bukan kejadian luar biasa - wrap error saat naik layer agar konteks bertambah
- cek error tepat setelah pemanggilan
- pilih pesan error yang informatif
Untuk packages
- jaga API package tetap kecil
- export hanya yang memang dipakai dari luar
- hindari package buangan
- pertimbangkan
internaluntuk menahan boundary
Untuk testing
- jadikan table-driven test sebagai default
- pakai subtest ketika skenario mulai banyak
- simpan fixture di
testdata/ - pastikan assertion memeriksa perilaku penting, bukan detail implementasi yang rapuh
Untuk project structure
- pisahkan entrypoint, domain, dan detail infrastruktur
- jangan biarkan
main.gomenjadi tempat semua logic - rancang folder berdasarkan tanggung jawab, bukan berdasarkan kebiasaan menumpuk file
Kesalahan umum
- menganggap error handling Go itu lambat hanya karena terlihat berulang
- mengabaikan wrapping sehingga root cause hilang saat debugging
- membuat package yang terlalu besar dan terlalu publik
- menulis test sekali jalan yang sulit ditambah case baru
- memakai struktur folder yang tidak memberi boundary nyata
Referensi terkait
errors.Isdanerrors.Asfmt.Errorfdengan%wtesting.Tdant.Runinternalpackage pattern- table-driven tests
- layout
cmd/daninternal/
Kalau diimplementasikan konsisten, empat hal ini biasanya cukup untuk membuat codebase Go tetap rapi saat project bertumbuh: error jelas, package terjaga, test mudah dibaca, dan struktur folder tidak liar.