Handler HTTP adalah kode yang paling sering berubah sekaligus paling sulit diuji: untuk mengujinya secara tradisional, kita harus menjalankan server, membuka port, lalu mengirim permintaan sungguhan — proses lambat yang membuat test jarang dijalankan. Package net/http/httptest membalik keadaan tersebut dengan dua alat: httptest.NewRequest membuat objek permintaan tanpa jaringan, dan httptest.NewRecorder merekam jawaban handler tanpa server. Pada chapter ini kita menguji dua handler JSON secara menyeluruh tanpa satu koneksi pun, lalu menutup dengan httptest.NewServer untuk kasus yang memang butuh server sungguhan di alamat loopback.
Dua Handler yang Diuji #
Kode produksi berisi dua handler standar: HandlerPengguna membalas satu objek JSON, sedangkan HandlerCari membaca parameter kueri q lalu membalas daftar produk yang cocok. Kedua handler memeriksa metode permintaan dan memakai http.Error untuk jalur kesalahan — perilaku yang jarang teruji bila test hanya mengincar jalur sukses:
package main
import (
"encoding/json"
"net/http"
"strings"
)
// Pengguna adalah data yang dikirim handler sebagai JSON.
type Pengguna struct {
Nama string `json:"nama"`
Email string `json:"email"`
}
// HandlerPengguna menangani GET /pengguna dan membalas satu data
// pengguna dalam format JSON.
func HandlerPengguna(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
pengguna := Pengguna{Nama: "Budi", Email: "[email protected]"}
if err := json.NewEncoder(w).Encode(pengguna); err != nil {
http.Error(w, "gagal menulis JSON", http.StatusInternalServerError)
}
}
// HandlerCari menangani GET /cari?q=kata dan membalas JSON berisi
// nama produk yang mengandung kata kunci q.
func HandlerCari(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
http.Error(w, "parameter q wajib diisi", http.StatusBadRequest)
return
}
produk := []string{"Buku Tulis", "Buku Gambar", "Pensil"}
var hasil []string
for _, p := range produk {
if strings.Contains(strings.ToLower(p), strings.ToLower(q)) {
hasil = append(hasil, p)
}
}
if err := json.NewEncoder(w).Encode(hasil); err != nil {
http.Error(w, "gagal menulis JSON", http.StatusInternalServerError)
}
}Handler-handler tersebut mengikuti pola dasar package net/http yang dibahas pada chapter HTTP dan Networking
; fokus kita di sini adalah cara mengujinya tanpa menjalankan http.ListenAndServe.
Test dengan NewRequest dan NewRecorder #
Berkas api_test.go menguji jalur sukses, metode salah, kueri kosong, hingga server penuh:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
)
func TestHandlerPengguna(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/pengguna", nil)
rec := httptest.NewRecorder()
HandlerPengguna(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q; want %q", got, "application/json")
}
var got Pengguna
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
want := Pengguna{Nama: "Budi", Email: "[email protected]"}
if got != want {
t.Errorf("got %+v; want %+v", got, want)
}
}
func TestHandlerPenggunaMethodSalah(t *testing.T) {
body := strings.NewReader(`{"nama":"Siti"}`)
req := httptest.NewRequest(http.MethodPost, "/pengguna?detail=penuh", body)
rec := httptest.NewRecorder()
HandlerPengguna(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d; want %d", rec.Code, http.StatusMethodNotAllowed)
}
}
func TestHandlerCari(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/cari?q=buku", nil)
rec := httptest.NewRecorder()
HandlerCari(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want %d", rec.Code, http.StatusOK)
}
var got []string
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
want := []string{"Buku Tulis", "Buku Gambar"}
if !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestHandlerCariTanpaQ(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/cari", nil)
rec := httptest.NewRecorder()
HandlerCari(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d; want %d", rec.Code, http.StatusBadRequest)
}
if teks := rec.Body.String(); !strings.Contains(teks, "wajib diisi") {
t.Errorf("body = %q; want pesan parameter q wajib diisi", teks)
}
}
func TestServerPenuh(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(HandlerPengguna))
defer server.Close()
resp, err := http.Get(server.URL + "/pengguna")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
var got Pengguna
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
want := Pengguna{Nama: "Budi", Email: "[email protected]"}
if got != want {
t.Errorf("got %+v; want %+v", got, want)
}
}Mari kita telaah baris demi baris berkas test di atas:
- Baris 13-15 (
httptest.NewRequestdanhttptest.NewRecorder): baris 13 membuat permintaanGET /penggunatanpa koneksi — URL string langsung diparse menjadi*http.Requestyang valid. Baris 14 membuatResponseRecorder, objek penggantihttp.ResponseWriteryang menampung status, header, dan body ke dalam memori. Baris 15 memanggil handler secara langsung. - Baris 17-19 (
rec.Code):rec.Codemenyimpan status jawaban handler; test membandingkannya denganhttp.StatusOKdan memakait.Fatalfkarena pemeriksaan lanjutan tidak bermakna bila status sudah salah. - Baris 20-22 (
rec.Header()): header jawaban terbaca lewatrec.Header().Get("Content-Type"), sama persis seperti membacahttp.Headerpada respons nyata. - Baris 23-30 (decode JSON):
rec.Bodybertipe*bytes.Buffer, jadijson.NewDecoder(rec.Body)men-decode body tanpa langkah ekstra. Hasil decode disimpan padagot, lalu dibandingkan dengan structwant— membandingkan struct hasil decode lebih tahan banting daripada membandingkan teks JSON mentah karena urutan dan spasi tidak lagi relevan. - Baris 33-42 (permintaan POST dengan body):
httptest.NewRequestjuga menerima body —strings.NewReaderdi baris 34 — dan URL dengan kueri, seperti/pengguna?detail=penuhdi baris 35. Handler menolak metode non-GET dengan status 405 dan test memastikan hal tersebut. - Baris 62-73 (
rec.Body.String()):rec.Body.String()mengembalikan seluruh body sebagai string untuk pemeriksaan teks; testTestHandlerCariTanpaQmemastikan pesan galat darihttp.Errorbenar-benar tercetak. - Baris 75-96 (
TestServerPenuh): kasus terakhir memakaihttptest.NewServer— dibahas pada bagian berikutnya.
Jalankan seluruh test dari direktori chapter:
go test -v ./...Output yang Dihasilkan:
=== RUN TestHandlerPengguna
--- PASS: TestHandlerPengguna (0.00s)
=== RUN TestHandlerPenggunaMethodSalah
--- PASS: TestHandlerPenggunaMethodSalah (0.00s)
=== RUN TestHandlerCari
--- PASS: TestHandlerCari (0.00s)
=== RUN TestHandlerCariTanpaQ
--- PASS: TestHandlerCariTanpaQ (0.00s)
=== RUN TestServerPenuh
--- PASS: TestServerPenuh (0.00s)
PASSNewRecorder: Seluruh Jalur Tanpa Jaringan #
Empat dari lima test pada berkas di atas berjalan tanpa jaringan: NewRequest menyiapkan permintaan, handler dipanggil, dan ResponseRecorder merekam hasil lengkap dengan status rec.Code, header rec.Header(), dan body rec.Body. Pola panggil-tiga-baris tersebut — buat request, buat recorder, panggil handler — menjadi kerangka tetap seluruh test handler, dan t.Run dari chapter Subtest
tinggal dibungkus di sekelilingnya untuk mengelompokkan kasus.
Perhatikan pula bahwa jalur kesalahan teruji setara jalur sukses: TestHandlerPenggunaMethodSalah membuktikan status 405 untuk metode salah, dan TestHandlerCariTanpaQ membuktikan status 400 beserta pesannya ketika parameter q kosong. Handler yang pengujian jalur salahnya terlewat adalah sumber status code yang mengejutkan di produksi.
httptest.NewServer: Server Sungguhan di Loopback #
Sebagian perilaku hanya muncul dengan server sungguhan: http.Client mengurus detail protokol — header Host, pembukaan koneksi, pembacaan body, hingga pengikutanan redirect — secara nyata. httptest.NewServer menjawab kebutuhan tersebut dengan menjalankan net/http.Server sungguhan pada alamat loopback 127.0.0.1 dan port acak yang tersedia — alamat lengkapnya tersimpan di server.URL. TestServerPenuh di baris 75-96 memakai pola lengkapnya: buat server dengan http.HandlerFunc(HandlerPengguna), defer server.Close(), lakukan http.Get(server.URL + "/pengguna"), dan periksa respons hasil melalui resp.StatusCode serta resp.Body.
| Alat | Menjalankan | Kapan dipakai |
|---|---|---|
httptest.NewRecorder | Handler saja | Uji status, header, body dari satu handler |
httptest.NewRequest | — (pembuat permintaan) | Siapkan *http.Request untuk recorder |
httptest.NewServer | Server di loopback | Uji interaksi client lengkap, middleware nyata, redirect |
Untuk handler yang menerima dan mengirim JSON dalam jumlah banyak, pembahasan validasi permintaan dan format respons berlanjut pada chapter REST API . Ketika seluruh lapisan — unit test, tabel kasus, helper, fixture, dan kini handler HTTP — tersusun rapi, pertanyaan berikutnya adalah seberapa banyak kode yang benar-benar tersentuh test; pengukurannya dibahas pada chapter Coverage .
Referensi #
- Dokumentasi package httptest
— referensi resmi
NewRecorder,NewRequest, danNewServer. - Dokumentasi package net/http
— referensi resmi
http.Handler,http.Error, dan kode status.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
api_test.go
Unduh berkaspackage main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
)
func TestHandlerPengguna(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/pengguna", nil)
rec := httptest.NewRecorder()
HandlerPengguna(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q; want %q", got, "application/json")
}
var got Pengguna
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
want := Pengguna{Nama: "Budi", Email: "[email protected]"}
if got != want {
t.Errorf("got %+v; want %+v", got, want)
}
}
func TestHandlerPenggunaMethodSalah(t *testing.T) {
body := strings.NewReader(`{"nama":"Siti"}`)
req := httptest.NewRequest(http.MethodPost, "/pengguna?detail=penuh", body)
rec := httptest.NewRecorder()
HandlerPengguna(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d; want %d", rec.Code, http.StatusMethodNotAllowed)
}
}
func TestHandlerCari(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/cari?q=buku", nil)
rec := httptest.NewRecorder()
HandlerCari(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want %d", rec.Code, http.StatusOK)
}
var got []string
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
want := []string{"Buku Tulis", "Buku Gambar"}
if !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestHandlerCariTanpaQ(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/cari", nil)
rec := httptest.NewRecorder()
HandlerCari(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d; want %d", rec.Code, http.StatusBadRequest)
}
if teks := rec.Body.String(); !strings.Contains(teks, "wajib diisi") {
t.Errorf("body = %q; want pesan parameter q wajib diisi", teks)
}
}
func TestServerPenuh(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(HandlerPengguna))
defer server.Close()
resp, err := http.Get(server.URL + "/pengguna")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
var got Pengguna
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
want := Pengguna{Nama: "Budi", Email: "[email protected]"}
if got != want {
t.Errorf("got %+v; want %+v", got, want)
}
}
api.go
Unduh berkaspackage main
import (
"encoding/json"
"net/http"
"strings"
)
// Pengguna adalah data yang dikirim handler sebagai JSON.
type Pengguna struct {
Nama string `json:"nama"`
Email string `json:"email"`
}
// HandlerPengguna menangani GET /pengguna dan membalas satu data
// pengguna dalam format JSON.
func HandlerPengguna(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
pengguna := Pengguna{Nama: "Budi", Email: "[email protected]"}
if err := json.NewEncoder(w).Encode(pengguna); err != nil {
http.Error(w, "gagal menulis JSON", http.StatusInternalServerError)
}
}
// HandlerCari menangani GET /cari?q=kata dan membalas JSON berisi
// nama produk yang mengandung kata kunci q.
func HandlerCari(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
http.Error(w, "parameter q wajib diisi", http.StatusBadRequest)
return
}
produk := []string{"Buku Tulis", "Buku Gambar", "Pensil"}
var hasil []string
for _, p := range produk {
if strings.Contains(strings.ToLower(p), strings.ToLower(q)) {
hasil = append(hasil, p)
}
}
if err := json.NewEncoder(w).Encode(hasil); err != nil {
http.Error(w, "gagal menulis JSON", http.StatusInternalServerError)
}
}