Menguji handler REST di Go: httptest dan pengujian berbasis tabel
Menguji handler REST Go dengan httptest dan kasus berbasis tabel memberi cara yang dapat diulang untuk memeriksa auth, validasi, kode status, dan edge case sebelum rilis.

Hal yang harus Anda yakini sebelum rilis
Sebuah handler REST bisa saja berhasil dikompilasi, lolos pengecekan manual cepat, dan tetap gagal di produksi. Sebagian besar kegagalan bukan masalah sintaks. Mereka masalah kontrak: handler menerima apa yang seharusnya ditolak, mengembalikan kode status yang salah, atau membocorkan detail dalam error.
Pengujian manual membantu, tapi mudah melewatkan edge case dan regresi. Anda mencoba jalur bahagia, mungkin satu error yang jelas, lalu lanjut. Lalu perubahan kecil pada validasi atau middleware diam-diam merusak perilaku yang Anda anggap stabil.
Tujuan tes handler sederhana: buat janji handler menjadi dapat diulang. Itu termasuk aturan autentikasi, validasi input, kode status yang dapat diprediksi, dan body error yang bisa diandalkan klien.
Paket httptest di Go sangat cocok karena Anda bisa menjalankan handler langsung tanpa menyalakan server sungguhan. Anda membangun request HTTP, mengoper ke handler, dan memeriksa body respons, header, dan kode status. Tes tetap cepat, terisolasi, dan mudah dijalankan pada setiap commit.
Sebelum rilis, Anda harus tahu (bukan berharap) bahwa:
- Perilaku auth konsisten untuk token yang hilang, token yang tidak valid, dan role yang salah.
- Input tervalidasi: field wajib, tipe, rentang, dan (jika Anda menegaskannya) field yang tidak dikenal.
- Kode status sesuai kontrak (misalnya 401 vs 403, 400 vs 422).
- Respons error aman dan konsisten (tidak ada stack trace, bentuk yang sama setiap kali).
- Non-happy path ditangani: timeout, kegagalan downstream, dan hasil kosong.
Endpoint “Buat tiket” mungkin bekerja saat Anda mengirim JSON sempurna sebagai admin. Tes menangkap apa yang Anda lupa coba: token kedaluwarsa, field ekstra yang dikirim klien secara tak sengaja, prioritas negatif, atau perbedaan antara “tidak ditemukan” dan “internal error” saat dependensi gagal.
Definisikan kontrak untuk tiap endpoint
Tuliskan apa yang dijanjikan handler sebelum Anda menulis tes. Kontrak yang jelas menjaga fokus tes dan mencegahnya berubah menjadi tebakan tentang apa yang “dimaksud” kode. Ini juga membuat refactor lebih aman karena Anda bisa mengubah bagian dalam tanpa mengubah perilaku.
Mulai dari input. Spesifik tentang dari mana tiap nilai datang dan apa yang wajib. Sebuah endpoint bisa mengambil id dari path, limit dari query string, header Authorization, dan body JSON. Catat aturan yang penting: format yang diperbolehkan, nilai min/max, field wajib, dan apa yang terjadi saat sesuatu hilang.
Lalu definisikan output. Jangan berhenti di “mengembalikan JSON.” Putuskan seperti apa sukses, header mana yang penting, dan bagaimana error terlihat. Jika klien bergantung pada kode error yang stabil dan bentuk JSON yang dapat diprediksi, anggap itu bagian dari kontrak.
Checklist praktis:
- Input: nilai path/query, header wajib, field JSON, dan aturan validasi
- Output: kode status, header respons, bentuk JSON untuk sukses dan error
- Efek samping: data apa yang berubah dan apa yang dibuat
- Dependensi: pemanggilan database, layanan eksternal, waktu sekarang, ID yang dihasilkan
Juga tentukan sampai di mana tes handler berhenti. Tes handler paling kuat pada boundary HTTP: auth, parsing, validasi, kode status, dan body error. Dorong kekhawatiran yang lebih dalam ke tes integrasi: query database nyata, panggilan jaringan, dan routing penuh.
Jika backend Anda digenerasi (misalnya AppMaster menghasilkan handler Go dan logika bisnis), pendekatan contract-first akan sangat berguna. Anda bisa menghasilkan ulang kode dan tetap memverifikasi bahwa tiap endpoint mempertahankan perilaku publik yang sama.
Siapkan harness minimal dengan httptest
Tes handler yang baik harus terasa seperti mengirim request nyata, tanpa menyalakan server. Di Go, itu biasanya berarti: bangun request dengan httptest.NewRequest, tangkap respons dengan httptest.NewRecorder, dan panggil handler Anda.
Memanggil handler langsung memberikan tes yang cepat dan terfokus. Ini ideal ketika Anda ingin memvalidasi perilaku di dalam handler: pengecekan auth, aturan validasi, kode status, dan body error. Menggunakan router di tes berguna ketika kontrak bergantung pada path params, route matching, atau urutan middleware. Mulailah dengan panggilan langsung dan tambahkan router hanya ketika perlu.
Header lebih penting daripada yang kebanyakan orang kira. Content-Type yang hilang bisa mengubah cara handler membaca body. Set header yang Anda harapkan di setiap kasus agar kegagalan menunjuk ke logika, bukan setup tes.
Berikut pola minimal yang bisa Anda pakai ulang:
req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
Untuk menjaga assertion konsisten, ada baiknya menggunakan satu helper kecil untuk membaca dan decode body respons. Pada kebanyakan tes, cek kode status dulu (biar kegagalan mudah dipindai), lalu header kunci yang Anda janjikan (sering Content-Type), lalu body.
Jika backend Anda digenerasi (termasuk backend Go yang dihasilkan oleh AppMaster), harness ini tetap berlaku. Anda menguji kontrak HTTP yang bergantung pada pengguna, bukan gaya kode di baliknya.
Desain kasus berbasis tabel yang tetap mudah dibaca
Tes berbasis tabel bekerja paling baik ketika tiap kasus terbaca seperti cerita kecil: request yang Anda kirim dan apa yang Anda harapkan kembali. Anda harus bisa memindai tabel dan memahami cakupan tanpa lompat-lompat di file.
Kasus yang solid biasanya memiliki: nama yang jelas, request (method, path, header, body), kode status yang diharapkan, dan pemeriksaan untuk respons. Untuk body JSON, lebih baik asert beberapa field stabil (seperti kode error) daripada mencocokkan seluruh string JSON, kecuali kontrak Anda menuntut output yang ketat.
Bentuk kasus sederhana yang bisa dipakai ulang
Jaga struct kasus tetap fokus. Letakkan setup sekali pakai dalam helper agar tabel tetap ringkas.
type tc struct {
name string
method string
path string
headers map[string]string
body string
wantStatus int
wantBody string // substring or compact JSON
}
Untuk input yang berbeda, gunakan string body kecil yang memperlihatkan perbedaan sekilas: payload valid, satu field yang hilang, satu tipe yang salah, dan satu string kosong. Hindari membuat JSON dengan banyak format di tabel — itu cepat membuat berantakan.
Saat Anda melihat setup berulang (pembuatan token, header umum, body default), pindahkan ke helper seperti newRequest(tc) atau baseHeaders().
Jika satu tabel mulai mencampur terlalu banyak ide, pisahkan. Satu tabel untuk jalur sukses dan tabel lain untuk jalur error sering lebih mudah dibaca dan debug.
Pengecekan auth: kasus yang biasanya terlewat
Tes auth sering terlihat baik di jalur bahagia, lalu gagal di produksi karena satu kasus “kecil” tidak pernah diuji. Perlakukan auth sebagai kontrak: apa yang dikirim klien, apa yang server kembalikan, dan apa yang tidak boleh diungkapkan.
Mulai dengan keberadaan token dan validitasnya. Endpoint yang dilindungi harus berperilaku berbeda ketika header hilang dibandingkan ketika ada tapi salah. Jika Anda memakai token berumur pendek, uji kadaluwarsa juga, meskipun Anda mencontohkannya dengan menyuntikkan validator yang mengembalikan “expired.”
Kebanyakan celah tercover oleh kasus-kasus ini:
- Tidak ada header
Authorization-> 401 dengan respons error yang stabil - Header malformed (prefix salah) -> 401
- Token tidak valid (signature buruk) -> 401
- Token kedaluwarsa -> 401 (atau kode yang Anda pilih) dengan pesan yang dapat diprediksi
- Token valid tapi role/izin salah -> 403
Perbedaan 401 vs 403 penting. Gunakan 401 saat pemanggil belum diautentikasi. Gunakan 403 saat mereka sudah diautentikasi tapi tidak diizinkan. Jika Anda mencampur keduanya, klien akan mencoba ulang tanpa perlu atau menampilkan UI yang salah.
Pengecekan role juga tidak cukup untuk endpoint “milik pengguna” (mis. GET /orders/{id}). Uji kepemilikan: user A tidak boleh melihat order user B meski token valid. Itu harus jadi 403 yang bersih (atau 404 jika Anda sengaja menyembunyikan keberadaan), dan body tidak boleh membocorkan apa pun. Jaga agar error bersifat generik. Jangan memberi petunjuk seperti “order milik user 42.”
Aturan input: validasi, tolak, dan jelaskan dengan jelas
Banyak bug pra-rilis adalah bug input: field hilang, tipe salah, format tak terduga, atau payload terlalu besar.
Sebutkan setiap input yang diterima handler Anda: field body JSON, query params, dan path params. Untuk masing-masing, putuskan apa yang terjadi bila hilang, kosong, salah format, atau di luar rentang. Lalu tulis kasus yang membuktikan handler menolak input buruk lebih awal dan selalu mengembalikan jenis error yang sama.
Satu set kecil kasus validasi biasanya menutup sebagian besar risiko:
- Field wajib: hilang vs string kosong vs null (jika Anda mengizinkan null)
- Tipe dan format: number vs string, format email/tanggal/UUID, parsing boolean
- Batas ukuran: panjang maks, jumlah maks, payload terlalu besar
- Field tak dikenal: diabaikan vs ditolak (jika Anda menerapkan decoding yang ketat)
- Query dan path params: hilang, tak bisa di-parse, dan perilaku default
Contoh: handler POST /users menerima { "email": "...", "age": 0 }. Uji email hilang, email = 123, email = "not-an-email", age = -1, dan age = "20". Jika Anda mengharuskan JSON ketat, juga uji { "email":"[email protected]", "extra":"x" } dan pastikan itu gagal.
Buat kegagalan validasi dapat diprediksi. Pilih kode status untuk error validasi (beberapa tim pakai 400, lain 422) dan pertahankan bentuk body error. Tes harus memeriksa status dan pesan (atau field details) yang mengarah ke input yang gagal.
Kode status dan body error: buat yang dapat diprediksi
Tes handler jadi lebih mudah saat kegagalan API membosankan dan konsisten. Anda ingin setiap error memetakan ke kode status yang jelas dan mengembalikan bentuk JSON yang sama, terlepas dari siapa yang menulis handler.
Mulai dengan pemetaan kecil yang disepakati dari tipe error ke HTTP status code:
- 400 Bad Request: JSON malform, query param wajib hilang
- 404 Not Found: ID resource tidak ada
- 409 Conflict: constraint unik atau konflik status
- 422 Unprocessable Entity: JSON valid tapi gagal aturan bisnis
- 500 Internal Server Error: kegagalan tak terduga (db down, nil pointer, outage pihak ketiga)
Lalu pertahankan body error yang stabil. Bahkan jika teks pesan berubah nanti, klien tetap punya field yang dapat diandalkan:
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
Di tes, asert bentuknya, bukan hanya status. Kegagalan umum adalah mengembalikan HTML, plain text, atau body kosong saat error, yang merusak klien dan menyembunyikan bug.
Juga uji header dan encoding untuk respons error:
Content-Typeadalahapplication/json(dan charset konsisten jika Anda menetapkannya)- Body adalah JSON valid bahkan saat error
code,message, dandetailsada (details bisa kosong, tapi tidak acak)- Panic dan error tak terduga mengembalikan 500 yang aman tanpa membocorkan stack trace
Jika Anda menambahkan middleware recover, sertakan satu tes yang memaksa panic dan konfirmasi Anda tetap mendapatkan respons error JSON yang bersih.
Edge case: kegagalan, waktu, dan non-happy path
Tes jalur bahagia membuktikan handler bekerja sekali. Tes edge-case membuktikan ia terus berperilaku saat dunia berantakan.
Paksa dependensi gagal dengan cara yang spesifik dan dapat diulang. Jika handler memanggil database, cache, atau API eksternal, Anda ingin melihat apa yang terjadi saat lapisan itu mengembalikan error yang tidak bisa Anda kendalikan.
Ini layak disimulasikan setidaknya sekali per endpoint:
- Timeout dari panggilan downstream (
context deadline exceeded) - Not found dari storage saat klien mengharapkan data
- Pelanggaran unique constraint pada create (email duplikat, slug duplikat)
- Error jaringan atau transport (connection refused, broken pipe)
- Error internal tak terduga ("something went wrong")
Jaga tes tetap stabil dengan mengontrol apa pun yang bisa berubah antar run. Tes flaky lebih buruk daripada tidak ada tes karena melatih orang untuk mengabaikan kegagalan.
Buat waktu dan randomness dapat diprediksi
Jika handler menggunakan time.Now(), ID, atau nilai acak, injeksikan mereka. Operkan fungsi clock dan generator ID ke handler atau service. Dalam tes, kembalikan nilai tetap sehingga Anda bisa mengasert field JSON dan header secara tepat.
Gunakan fake kecil, dan asert "tidak ada efek samping"
Lebih suka fake atau stub kecil daripada mock penuh. Fake bisa merekam panggilan dan membuat Anda bisa mengasert bahwa tidak ada yang terjadi setelah kegagalan.
Contoh: pada handler "create user", jika insert ke database gagal karena unique constraint, asert kode status benar, body error stabil, dan tidak ada email sambutan yang dikirim. Fake mailer Anda bisa mengekspos counter (sent=0) sehingga jalur gagal membuktikan tidak memicu efek samping.
Kesalahan umum yang membuat tes handler tidak dapat diandalkan
Tes handler sering gagal karena alasan yang salah. Request yang Anda buat di tes tidak berbentuk sama dengan request klien nyata. Itu menyebabkan kegagalan berisik dan keyakinan palsu.
Salah satu masalah umum adalah mengirim JSON tanpa header yang handler harapkan. Jika kode Anda memeriksa Content-Type: application/json, melupakannya bisa membuat handler melewatkan decoding JSON, mengembalikan kode status berbeda, atau mengambil cabang yang tak terjadi di produksi. Hal yang sama berlaku untuk auth: header Authorization yang hilang tidak sama dengan token tidak valid. Itu harus jadi kasus berbeda.
Perangkap lain adalah mengasert seluruh respons JSON sebagai string mentah. Perubahan kecil seperti urutan field, spasi, atau field baru merusak tes meski API masih benar. Decode body ke struct atau map[string]any, lalu asert yang penting: status, kode error, pesan, dan beberapa field kunci.
Tes juga menjadi tidak andal saat kasus berbagi state yang bisa diubah. Menggunakan store in-memory yang sama, variabel global, atau router singleton across table rows bisa menyebabkan data bocor antar kasus. Setiap test case harus mulai bersih, atau reset state di t.Cleanup.
Polanya yang biasanya membuat tes rapuh:
- Membangun request tanpa header dan encoding yang klien nyata gunakan
- Mengasert string JSON penuh alih-alih decode dan memeriksa field
- Menggunakan kembali database/cache/global handler state di seluruh kasus
- Memadukan auth, validasi, dan assertion logika bisnis dalam satu tes yang terlalu besar
Jaga tiap tes fokus. Jika satu kasus gagal, Anda harus tahu apakah itu karena auth, aturan input, atau format error dalam hitungan detik.
Checklist cepat pra-rilis yang bisa Anda pakai ulang
Sebelum Anda kirim, tes harus membuktikan dua hal: endpoint mengikuti kontraknya, dan gagal dengan cara yang aman dan dapat diprediksi.
Jalankan ini sebagai kasus berbasis tabel, dan buat tiap kasus mengasert respons dan efek sampingnya:
- Auth: tidak ada token, token buruk, role salah, role benar (dan konfirmasi kasus "role salah" tidak membocorkan detail)
- Input: field wajib hilang, tipe salah, batas ukuran (min/max), field tak dikenal yang ingin Anda tolak
- Output: kode status, header kunci (seperti
Content-Type), field JSON wajib, bentuk error konsisten - Dependensi: paksa satu kegagalan downstream (DB, queue, payment, email), verifikasi pesan yang aman, konfirmasi tidak ada partial write
- Idempotency: ulangi request yang sama (atau retry setelah timeout) dan konfirmasi Anda tidak membuat duplikat
Setelah itu, tambahkan satu asert sanity yang sering dilewatkan: konfirmasi handler tidak menyentuh hal yang tidak seharusnya. Misalnya, pada kasus validasi gagal, verifikasi tidak ada record dibuat dan tidak ada email dikirim.
Jika Anda membangun API dengan alat seperti AppMaster, checklist yang sama tetap berlaku. Intinya sama: buktikan perilaku publik tetap stabil.
Contoh: satu endpoint, tabel kecil, dan apa yang tertangkap
Misalkan Anda punya endpoint sederhana: POST /login. Menerima JSON dengan email dan password. Mengembalikan 200 dengan token saat sukses, 400 untuk input tidak valid, 401 untuk kredensial salah, dan 500 jika servis auth down.
Tabel ringkas seperti ini menutup sebagian besar yang sering rusak di produksi.
func TestLoginHandler(t *testing.T) {
// Fake dependency so we can force 200/401/500 without hitting real systems.
auth := &FakeAuth{ /* configure per test */ }
h := NewLoginHandler(auth)
tests := []struct {
name string
body string
authHeader string
setup func()
wantStatus int
wantBody string
}{
{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
}
if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
}
})
}
}
Jalankan satu kasus end-to-end: untuk “missing password,” Anda mengirim body dengan hanya email, set Content-Type, jalankan lewat ServeHTTP, lalu asert 400 dan error yang jelas menunjuk pada password. Kasus tunggal itu membuktikan decoder, validator, dan format respons error bekerja bersama.
Jika Anda ingin cara cepat untuk menstandarkan kontrak, modul auth, dan integrasi sambil tetap menghasilkan kode Go nyata, AppMaster (appmaster.io) dibuat untuk itu. Meski begitu, tes ini tetap bernilai karena mengunci perilaku yang diandalkan klien.


