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:

go
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:

go
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:

  1. Baris 13-15 (httptest.NewRequest dan httptest.NewRecorder): baris 13 membuat permintaan GET /pengguna tanpa koneksi — URL string langsung diparse menjadi *http.Request yang valid. Baris 14 membuat ResponseRecorder, objek pengganti http.ResponseWriter yang menampung status, header, dan body ke dalam memori. Baris 15 memanggil handler secara langsung.
  2. Baris 17-19 (rec.Code): rec.Code menyimpan status jawaban handler; test membandingkannya dengan http.StatusOK dan memakai t.Fatalf karena pemeriksaan lanjutan tidak bermakna bila status sudah salah.
  3. Baris 20-22 (rec.Header()): header jawaban terbaca lewat rec.Header().Get("Content-Type"), sama persis seperti membaca http.Header pada respons nyata.
  4. Baris 23-30 (decode JSON): rec.Body bertipe *bytes.Buffer, jadi json.NewDecoder(rec.Body) men-decode body tanpa langkah ekstra. Hasil decode disimpan pada got, lalu dibandingkan dengan struct want — membandingkan struct hasil decode lebih tahan banting daripada membandingkan teks JSON mentah karena urutan dan spasi tidak lagi relevan.
  5. Baris 33-42 (permintaan POST dengan body): httptest.NewRequest juga menerima body — strings.NewReader di baris 34 — dan URL dengan kueri, seperti /pengguna?detail=penuh di baris 35. Handler menolak metode non-GET dengan status 405 dan test memastikan hal tersebut.
  6. Baris 62-73 (rec.Body.String()): rec.Body.String() mengembalikan seluruh body sebagai string untuk pemeriksaan teks; test TestHandlerCariTanpaQ memastikan pesan galat dari http.Error benar-benar tercetak.
  7. Baris 75-96 (TestServerPenuh): kasus terakhir memakai httptest.NewServer — dibahas pada bagian berikutnya.

Jalankan seluruh test dari direktori chapter:

bash
go test -v ./...

Output yang Dihasilkan:

text
=== 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)
PASS

NewRecorder: 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.

AlatMenjalankanKapan dipakai
httptest.NewRecorderHandler sajaUji status, header, body dari satu handler
httptest.NewRequest— (pembuat permintaan)Siapkan *http.Request untuk recorder
httptest.NewServerServer di loopbackUji 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

api_test.go

Unduh berkas
go
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)
	}
}

api.go

Unduh berkas
go
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)
	}
}