Belajar Golang dari Nol #14: Testing, Menguji Function dan Handler
Di Belajar Golang dari Nol #13, API produk kita sudah punya middleware logging dan proteksi API key. Endpoint aman, setiap request tercatat. Tapi ada satu kebiasaan yang masih kita lakukan sejak bagian 11. Setiap selesai mengubah kode, kita jalankan server, buka Postman, lalu klik kirim request satu per satu untuk memastikan semuanya masih jalan.
Cara itu melelahkan. Dan yang lebih bahaya, gampang kelewat. Kamu perbaiki handler POST, lupa cek handler GET, ternyata yang GET ikut rusak. Di bagian ini kita belajar cara yang lebih waras, yaitu menyuruh Go sendiri yang mengetes kode kita. Namanya automated testing, dan Go menyediakannya bawaan tanpa install apa pun.
Kenapa Testing Bukan Kemewahan
Banyak pemula menganggap testing itu urusan nanti, sesuatu yang dikerjakan kalau sempat. Padahal ada tiga alasan kenapa testing justru menghemat waktu.
- Refactor tanpa takut. Mau ganti struktur kode, rapikan function, atau pindah logika ke file lain. Selama test lulus, kamu tahu perilaku kode tidak berubah. Tanpa test, setiap refactor terasa seperti jalan di atas es tipis.
- Bukti kode jalan. Satu perintah
go testmenggantikan puluhan klik manual di Postman. Sekali tulis, tes bisa diulang ribuan kali secara gratis. - Syarat kerja tim. Di hampir semua tim backend profesional, kode tanpa test tidak akan lolos code review. Kalau kamu berencana kerja sebagai backend developer Go, kebiasaan menulis test bukan nilai plus lagi. Itu standar minimum.
Kabar baiknya, Go serius soal ini. Tool testing sudah ada di dalam bahasa lewat package testing dan perintah go test. Tidak perlu install framework pihak ketiga seperti di bahasa lain.
Aturan Dasar Testing di Go
Go punya konvensi sederhana. Ikuti aturannya, dan semuanya jalan otomatis.
- File test diberi akhiran
_test.go. Contoh: kode ada diproduk.go, testnya diproduk_test.go. File ini tidak ikut ter-compile saat build biasa. - Function test harus diawali
Testdengan huruf besar setelahnya, dan menerima satu parametert *testing.T. Contoh:func TestHitungDiskon(t *testing.T). - Jalankan dengan
go testdi folder project. Tambahkan-vkalau mau lihat detail tiap test.
go test # jalankan semua test, hanya tampilkan ringkasan
go test -v # verbose, tampilkan tiap test dan hasilnya
Tidak ada function main yang perlu dipanggil. Tidak ada registrasi test di mana pun. Go mencari sendiri semua file _test.go dan menjalankan semua function TestXxx di dalamnya.
Test Pertama: Menguji Function Murni
Target paling enak untuk test pertama adalah function murni, yaitu function yang hasilnya hanya bergantung pada input. Tidak menyentuh database, tidak menyentuh network. Dari seri sebelumnya kita sudah punya dua kandidat, yaitu validasi produk dan hitung diskon. Ini versi ringkasnya di produk.go.
func ValidasiProduk(p Produk) error {
if p.Nama == "" {
return errors.New("nama produk tidak boleh kosong")
}
if p.Harga <= 0 {
return errors.New("harga harus lebih dari nol")
}
if p.Stok < 0 {
return errors.New("stok tidak boleh minus")
}
return nil
}
func HitungDiskon(harga, persen int) (int, error) {
if persen < 0 || persen > 100 {
return 0, errors.New("persen diskon harus 0 sampai 100")
}
return harga - (harga * persen / 100), nil
}
Sekarang buat file produk_test.go di folder yang sama, lalu tulis test pertama.
package main
import "testing"
func TestHitungDiskon(t *testing.T) {
got, err := HitungDiskon(100000, 10)
if err != nil {
t.Fatalf("tidak mengharapkan error, dapat: %v", err)
}
want := 90000
if got != want {
t.Errorf("HitungDiskon(100000, 10) = %d, ingin %d", got, want)
}
}
Polanya selalu sama. Panggil function dengan input tertentu, simpan hasilnya di got, bandingkan dengan nilai harapan di want. Kalau beda, laporkan lewat t.Errorf. Perhatikan pesan errornya. Pesan yang baik menyebut input, hasil yang didapat, dan hasil yang diinginkan. Saat test gagal enam bulan lagi, pesan itu yang menyelamatkanmu dari menebak-nebak.
Ada dua cara melaporkan kegagalan. t.Errorf menandai test gagal tapi lanjut ke baris berikutnya. t.Fatalf menandai gagal dan langsung berhenti. Pakai Fatalf kalau pengecekan berikutnya tidak ada artinya lagi, misalnya errornya saja sudah muncul.
Table Driven Test, Idiom Paling Penting
Satu function biasanya perlu diuji dengan banyak kombinasi input. Menyalin test di atas lima kali dengan angka berbeda jelas bukan solusi. Idiom Go untuk masalah ini bernama table driven test. Idenya, kumpulkan semua kasus dalam satu slice of struct, lalu loop.
func TestHitungDiskonTable(t *testing.T) {
kasus := []struct {
nama string
harga int
persen int
want int
inginErr bool
}{
{"diskon normal 10 persen", 100000, 10, 90000, false},
{"tanpa diskon", 50000, 0, 50000, false},
{"diskon penuh", 80000, 100, 0, false},
{"persen minus harus gagal", 100000, -5, 0, true},
{"persen di atas 100 harus gagal", 100000, 150, 0, true},
}
for _, k := range kasus {
t.Run(k.nama, func(t *testing.T) {
got, err := HitungDiskon(k.harga, k.persen)
if k.inginErr {
if err == nil {
t.Errorf("mengharapkan error, tapi dapat nil")
}
return
}
if err != nil {
t.Fatalf("tidak mengharapkan error, dapat: %v", err)
}
if got != k.want {
t.Errorf("dapat %d, ingin %d", got, k.want)
}
})
}
}
Setiap kasus punya nama, dan t.Run menjalankannya sebagai subtest terpisah. Saat go test -v jalan, kamu bisa lihat kasus mana yang lulus dan mana yang gagal, lengkap dengan namanya.
Kenapa pola ini enak dirawat. Menambah kasus baru cukup satu baris di tabel, bukan satu function baru. Logika pengujian ditulis sekali, tidak ada copy paste yang bisa melenceng. Dan tabelnya sendiri menjadi dokumentasi perilaku function.
Menguji Kasus yang Harus Gagal
Pemula sering hanya menguji jalur bahagia, yaitu input benar menghasilkan output benar. Padahal bug paling sering muncul di jalur sebaliknya. Input jelek harus ditolak, dan test harus memastikan penolakan itu benar-benar terjadi.
func TestValidasiProdukStokMinus(t *testing.T) {
p := Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}
err := ValidasiProduk(p)
if err == nil {
t.Errorf("stok minus seharusnya gagal validasi, tapi error nil")
}
}
Perhatikan logikanya terbalik dari test biasa. Di sini kondisi gagal justru err == nil. Kalau suatu hari ada yang tidak sengaja menghapus pengecekan stok di ValidasiProduk, test ini langsung merah. Tanpa test ini, produk berstok minus bisa masuk database dan baru ketahuan saat pelanggan komplain.
Menguji Handler HTTP dengan httptest
Function murni sudah aman. Sekarang bagian yang lebih menarik, yaitu menguji handler API tanpa menjalankan server sama sekali. Go menyediakan package net/http/httptest untuk ini. Dua pemain utamanya adalah httptest.NewRequest untuk membuat request palsu dan httptest.NewRecorder untuk menangkap response.
Supaya fokus ke teknik testingnya, kita pakai handler versi in-memory yang sederhana. Di project kamu, handler dari bagian 11 dan 12 bisa diuji dengan pola yang sama persis.
var daftarProduk = []Produk{
{ID: 1, Nama: "Kopi Gayo", Harga: 45000, Stok: 10},
{ID: 2, Nama: "Teh Melati", Harga: 20000, Stok: 25},
}
func handlerListProduk(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(daftarProduk)
}
func handlerTambahProduk(w http.ResponseWriter, r *http.Request) {
var p Produk
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "body JSON tidak valid", http.StatusBadRequest)
return
}
if err := ValidasiProduk(p); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p.ID = len(daftarProduk) + 1
daftarProduk = append(daftarProduk, p)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(p)
}
Menguji handler GET produk
func TestHandlerListProduk(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/produk", nil)
rec := httptest.NewRecorder()
handlerListProduk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
}
var hasil []Produk
if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
t.Fatalf("body bukan JSON valid: %v", err)
}
if len(hasil) == 0 {
t.Errorf("daftar produk kosong, ingin minimal 1 produk")
}
}
Alurnya tiga langkah. Buat request palsu, siapkan recorder, panggil handler seperti function biasa. Recorder menyimpan semua yang ditulis handler, yaitu status code di rec.Code dan body di rec.Body. Kita cek status 200, lalu pastikan bodynya JSON valid. Semua terjadi di memori, tanpa port terbuka, tanpa server jalan, selesai dalam hitungan milidetik.
Menguji POST dengan body invalid
Sekarang jalur gagalnya. Kirim produk dengan nama kosong, dan handler harus menjawab 400.
func TestHandlerTambahProdukBodyInvalid(t *testing.T) {
body := strings.NewReader(`{"nama": ""}`)
req := httptest.NewRequest(http.MethodPost, "/produk", body)
rec := httptest.NewRecorder()
handlerTambahProduk(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, ingin %d", rec.Code, http.StatusBadRequest)
}
}
Body request dibuat dari string biasa lewat strings.NewReader. Test ini menjaga kontrak API kita. Selama test ini lulus, klien yang mengirim data kacau dijamin dapat 400, bukan 500 atau lebih parah lagi, data kacau yang tersimpan diam-diam.
Menguji Endpoint yang Dilindungi API Key
Di bagian 13 kita membungkus handler dengan middleware cekAPIKey yang memeriksa header X-API-Key. Middleware juga bisa diuji dengan httptest. Kuncinya, bungkus handler dengan middleware dulu, lalu panggil lewat ServeHTTP.
func TestMiddlewareAPIKey(t *testing.T) {
handler := cekAPIKey(http.HandlerFunc(handlerListProduk))
t.Run("tanpa API key ditolak", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/produk", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, ingin %d", rec.Code, http.StatusUnauthorized)
}
})
t.Run("dengan API key benar lolos", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/produk", nil)
req.Header.Set("X-API-Key", "rahasia123")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, ingin %d", rec.Code, http.StatusOK)
}
})
}
Dua skenario dalam satu test. Request tanpa header harus mentok di 401, request dengan key yang benar harus tembus dan dapat 200. Test seperti ini penting karena bug di lapisan auth adalah bug keamanan. Kalau ada perubahan yang tidak sengaja mematikan pengecekan API key, test ini langsung gagal sebelum kode naik ke server.
Coverage: Melihat Bagian yang Belum Teruji
Go bisa menghitung berapa persen baris kode yang tersentuh test.
$ go test -cover
PASS
coverage: 75.9% of statements
ok toko-api 0.018s
Satu hal yang perlu diluruskan sejak awal. Angka coverage bukan tujuan, melainkan petunjuk. Coverage 75.9% artinya masih ada sekitar seperempat kode yang belum pernah dijalankan oleh test mana pun, dan di situlah bug bisa bersembunyi. Mengejar 100% dengan test asal-asalan jauh lebih buruk daripada 75% dengan test yang benar-benar memeriksa perilaku penting. Pakai coverage untuk menemukan lubang, bukan untuk pamer angka.
Kebiasaan Sehat Menulis Test
- Test kecil dan cepat. Satu test menguji satu perilaku. Test yang lambat akan malas dijalankan, dan test yang tidak dijalankan sama saja tidak ada.
- Nama test menjelaskan skenario.
TestHandlerTambahProdukBodyInvalidlangsung terbaca maksudnya.TestProduk2tidak menceritakan apa pun. - Jalankan sebelum commit. Biasakan
go test ./...sebelumgit commit. Perintah ini menjalankan test di semua package dalam project. - Kenali t.Helper. Kalau kamu membuat function bantuan untuk test, panggil
t.Helper()di baris pertamanya. Dengan begitu saat gagal, Go menunjuk baris pemanggilnya, bukan baris di dalam helper. Cukup tahu dulu, kamu akan membutuhkannya saat file test mulai panjang.
Latihan: produk_test.go Utuh
Sebagai latihan penutup, gabungkan semuanya. Ini file test untuk function validasi CRUD produk dengan table driven, ditambah satu handler test.
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestValidasiProduk(t *testing.T) {
kasus := []struct {
nama string
produk Produk
inginErr bool
}{
{"produk valid", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: 10}, false},
{"nama kosong", Produk{Nama: "", Harga: 45000, Stok: 10}, true},
{"harga nol", Produk{Nama: "Kopi Gayo", Harga: 0, Stok: 10}, true},
{"harga minus", Produk{Nama: "Kopi Gayo", Harga: -100, Stok: 10}, true},
{"stok minus", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}, true},
}
for _, k := range kasus {
t.Run(k.nama, func(t *testing.T) {
err := ValidasiProduk(k.produk)
if k.inginErr && err == nil {
t.Errorf("mengharapkan error, tapi dapat nil")
}
if !k.inginErr && err != nil {
t.Errorf("tidak mengharapkan error, dapat: %v", err)
}
})
}
}
func TestHandlerListProduk(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/produk", nil)
rec := httptest.NewRecorder()
handlerListProduk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
}
var hasil []Produk
if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
t.Fatalf("body bukan JSON valid: %v", err)
}
}
Jalankan dengan go test -v. Kalau semuanya benar, hasilnya seperti ini.
$ go test -v
=== RUN TestValidasiProduk
=== RUN TestValidasiProduk/produk_valid
=== RUN TestValidasiProduk/nama_kosong
=== RUN TestValidasiProduk/harga_nol
=== RUN TestValidasiProduk/harga_minus
=== RUN TestValidasiProduk/stok_minus
--- PASS: TestValidasiProduk (0.00s)
--- PASS: TestValidasiProduk/produk_valid (0.00s)
--- PASS: TestValidasiProduk/nama_kosong (0.00s)
--- PASS: TestValidasiProduk/harga_nol (0.00s)
--- PASS: TestValidasiProduk/harga_minus (0.00s)
--- PASS: TestValidasiProduk/stok_minus (0.00s)
=== RUN TestHandlerListProduk
--- PASS: TestHandlerListProduk (0.00s)
PASS
ok toko-api 0.017s
Barisan PASS itu bukan sekadar hiasan. Itu bukti tertulis bahwa validasi produk dan handler API kamu berperilaku sesuai harapan, dan bukti itu bisa diperbarui kapan saja dengan satu perintah. Coba tambahkan sendiri test untuk handlerTambahProduk dengan kasus sukses, lalu ukur lagi dengan go test -cover dan lihat angkanya naik.
Selanjutnya: Naik ke Server
API produk kita sekarang punya endpoint CRUD, database, middleware, proteksi API key, dan barisan test yang menjaga semuanya. Tinggal satu langkah besar yang belum, yaitu keluar dari laptop. Di bagian 15, Deploy API Go ke Server: dari Laptop ke Production, kita akan membawa API ini ke server sungguhan supaya bisa diakses siapa pun.
Kalau kamu ingin membangun API atau sistem aplikasi yang teruji dan siap production tanpa mengerjakan semuanya sendiri, tim Arrazy bisa membantu dari perancangan sampai deployment.
Artikel Lainnya di Kategori Golang
Golang 4 Agustus 2026
Belajar Golang dari Nol #9: Interface, Kontrak yang Bikin Kode Fleksibel
Lanjutan seri Go pemula: kenali interface, implementasi implisit, fmt.Stringer, sampai latihan sistem pembayaran dengan dua metode berbeda.
Baca Artikel
Golang 6 Agustus 2026
Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http
Seri Go pemula sampai di gerbang backend: bikin REST API dengan net/http, routing method Go 1.22, JSON response, dan API produk mini lengkap.
Baca Artikel
Golang 27 Juli 2026
Belajar Golang dari Nol #1: Kenalan dengan Go dan Cara Installnya
Kenalan dengan Go: bahasa apa, siapa pemakainya, cara installnya di Linux, Windows, macOS, sampai program pertama. Panduan pemula tanpa ribet.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel