Web Artisan
Beranda

Progress belajar

Modul 50 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 6 · Testing

Testing HTTP Handler
dengan httptest

Uji perilaku API Go tanpa membuka port, tanpa menjalankan server sungguhan, dan tanpa menyentuh database.

Bahasa: Go 1.26~72 menit baca
01

Kenapa Handler Perlu Dites?

Handler adalah kontrak HTTP yang dilihat frontend, mobile app, dan integrasi eksternal.

Unit test di modul sebelumnya memastikan logic murni benar, sedangkan handler test memastikan logic itu keluar sebagai API behavior yang benar.

Di React atau Next.js, kamu mungkin terbiasa mengetes API call dengan MSW atau Supertest. Di Laravel, kamu mungkin mengenal feature test seperti getJson() lalu cek status dan payload. Di Go, untuk level handler, kita bisa memakai package standar testing dan net/http/httptest tanpa menjalankan server sungguhan.

Di modul ini kita menguji tiga keluarga endpoint inti online shop skincare, bukan hanya produk. Pola yang sama berlaku untuk ketiganya.

GET /v1/products/{id} Ambil detail produk skincare berdasarkan ID produk
POST /v1/cart/items Tambah item ke cart milik user yang sedang login
POST /v1/orders Checkout cart menjadi order baru dengan total dalam rupiah utuh

Tujuan test handler bukan membuktikan SQL benar. Tujuannya membuktikan request HTTP diparse dengan benar, service dipanggil dengan input benar, error domain dipetakan ke status code yang benar, dan response JSON mudah dikonsumsi client.

Letakkan handler test di tengah piramida testing. Ia lebih luas daripada unit test logic murni di modul sebelumnya, tetapi tetap jauh lebih cepat dan stabil daripada integration test yang menyentuh PostgreSQL.

flowchart TB
  E2E["E2E test<br/>server nyata + DB + webhook<br/>sedikit, lambat"]
  INT["Integration test<br/>handler + repository + PostgreSQL<br/>sedang"]
  HND["Handler test (httptest)<br/>handler + mock service, di memori<br/>banyak, cepat"]
  UNIT["Unit test<br/>logic murni: total cart, voucher<br/>paling banyak, paling cepat"]
  E2E --> INT --> HND --> UNIT

Gambar 1. Handler test mengisi lapisan kedua dari bawah, menguji kontrak HTTP product, cart, dan order tanpa biaya database.

🌉Jembatan: dari feature test Laravel ke httptest

Laravel feature test terasa seperti mengirim request ke aplikasi, sedangkan Go handler test biasanya memanggil ServeHTTP langsung di memori. Lebih kecil cakupannya, lebih cepat, dan lebih mudah mengisolasi service.

02

Mental Model httptest

Kita mengganti jaringan nyata dengan object request dan recorder di memori.

httptest membuat simulasi HTTP yang cukup realistis untuk handler, tetapi tetap ringan seperti unit test.

httptest.NewRequest

Membuat *http.Request server-side untuk test, misalnya request GET /v1/products/42 dengan body nil atau JSON.

httptest.NewRecorder

Membuat *httptest.ResponseRecorder, pengganti http.ResponseWriter yang menyimpan status code, header, dan body yang ditulis handler.

handler.ServeHTTP(w, r)

Memanggil handler langsung dengan recorder dan request. Tidak ada socket, tidak ada port, tidak ada proses server terpisah.

sequenceDiagram
  participant Test as Test Function
  participant Req as httptest Request
  participant Router as chi Router
  participant Handler as Product Handler
  participant Service as Mock Service
  participant Rec as ResponseRecorder
  Test->>Req: NewRequest GET /v1/products/42
  Test->>Rec: NewRecorder
  Test->>Router: ServeHTTP(rec, req)
  Router->>Handler: route matched
  Handler->>Service: GetByID(ctx, 42)
  Service-->>Handler: Product or ErrNotFound
  Handler-->>Rec: WriteHeader and JSON body
  Test->>Rec: assert Code and Body

Gambar 2. Handler test mengganti request dan response writer nyata dengan object test di memori.

JS / PHP
  • Di Jest atau Supertest, kamu sering menulis expect(response.status).toBe(200).
  • Di Laravel, kamu sering menulis assertStatus(200) dan assertJson().
Go
  • Di Go, kamu cek w.Code untuk status dan w.Body.String() atau json.Unmarshal untuk response.
  • Dependency luar diganti dengan mock kecil yang memenuhi interface service.
03

Anatomi Handler yang Mudah Dites

Handler yang testable tidak membuat dependency sendiri di dalam method.

Agar bisa dites tanpa database, handler menerima service lewat interface.

Struktur kecil untuk domain produk terlihat seperti ini. Handler hanya tahu interface service, bukan pgx, bukan repository konkret, dan bukan koneksi PostgreSQL.

Struktur handler test per domain
  • internal/
  • product/
  • handler.go GET produk, parse param, tulis JSON
  • handler_test.go httptest untuk success dan not found
  • service.go interface ProductService nyata di proyek
  • model.go Product dan response DTO
  • cart/
  • handler.go POST item ke cart user
  • handler_test.go httptest untuk add item dan validasi
  • order/
  • handler.go POST checkout cart jadi order
  • handler_test.go httptest untuk created dan stok habis
  • middleware/
  • auth.go ambil user dari Authorization header
  • auth_test.go ServeHTTP menguji 401 vs 200
  • shared/
  • errors.go ErrNotFound, ErrValidation, ErrConflict

Handler

Parse route param, panggil service, tulis status code dan JSON response.

Service

Berisi business logic, misalnya produk aktif, archived, atau tidak ditemukan.

Mock service

Implementasi kecil di file test untuk mengatur hasil success atau error.

💡Prinsip desain

Accept interfaces, return structs. Handler menerima interface service agar test bisa memberi fake, sedangkan service nyata tetap mengembalikan struct domain yang jelas.

04

NewRecorder dan NewRequest

Dua object ini adalah pasangan utama di handler test.

NewRequest membuat input, NewRecorder menangkap output.

internal/product/handler_test.go
req := httptest.NewRequest(http.MethodGet, "/v1/products/42", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK) } body := w.Body.String() if body == "" { t.Errorf("response body kosong") }

httptest.NewRequest() berbeda dari http.NewRequest(). Untuk handler test, gunakan httptest.NewRequest() karena request yang dibuat cocok untuk server handler. httptest.NewRecorder() mengimplementasikan http.ResponseWriter, sehingga handler tidak perlu tahu bahwa ia sedang dites.

⚠️Jebakan: w.Code bisa 0

Jika handler tidak pernah memanggil WriteHeader atau Write, w.Code bisa bernilai 0. Untuk API JSON, biasakan menulis status secara eksplisit lewat helper seperti writeJSON(w, status, payload).

05

Menjalankan Handler Langsung

Tidak perlu ListenAndServe, tidak perlu port lokal.

Setiap http.Handler punya method ServeHTTP, dan itulah titik masuk yang kita panggil di test.

internal/product/handler_test.go
handler := NewHandler(service).Routes() req := httptest.NewRequest(http.MethodGet, "/v1/products/42", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req)

Di contoh ini handler adalah router chi yang berisi route detail produk. Kita tetap memanggil ServeHTTP agar chi punya kesempatan mencocokkan route, mengisi route param, lalu meneruskan request ke method handler yang tepat.

🌉Jembatan: dari controller Laravel

Laravel test memanggil aplikasi lewat kernel HTTP framework. Di Go, router seperti chi sudah merupakan http.Handler, jadi kita bisa memanggilnya langsung tanpa boot server.

06

Mock Service Layer dengan Interface

Kita menguji HTTP behavior, bukan database behavior.

Mock service membuat handler test tetap fokus pada request, status code, dan response JSON.

Interface service untuk handler produk cukup kecil. Handler tidak peduli apakah implementasinya memakai PostgreSQL, cache, atau fake di test.

internal/product/handler.go
type ProductService interface { GetByID(ctx context.Context, id int64) (Product, error) }

Di test, kita buat mock manual. Untuk kasus sederhana, mock manual lebih jelas daripada library mocking karena pembaca bisa melihat input dan outputnya langsung.

internal/product/handler_test.go
type mockProductService struct { getByIDFunc func(ctx context.Context, id int64) (Product, error) } func (m mockProductService) GetByID(ctx context.Context, id int64) (Product, error) { return m.getByIDFunc(ctx, id) }
📝Catatan

Mock manual cocok untuk handler dan service kecil. Saat interface mulai besar, itu sering menjadi sinyal desain bahwa interface terlalu lebar untuk kebutuhan handler.

07

Contoh Lengkap: GET Produk

Success dan not found dalam satu file test yang idiomatik.

Contoh ini menguji GET /v1/products/42 untuk response berhasil dan GET /v1/products/404 untuk response tidak ditemukan.

Pertama, bentuk handler produksi yang akan dites. Perhatikan bahwa handler menerima ProductService, memakai chi.URLParam, dan menulis JSON secara eksplisit.

internal/product/handler.go
package product import ( "context" "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" ) var ErrNotFound = errors.New("product not found") type Product struct { ID int64 Name string Brand string PriceRupiah int64 } type ProductService interface { GetByID(ctx context.Context, id int64) (Product, error) } type Handler struct { service ProductService } func NewHandler(service ProductService) *Handler { return &Handler{service: service} } func (h *Handler) Routes() http.Handler { r := chi.NewRouter() r.Get("/v1/products/{id}", h.GetByID) return r } type productResponse struct { ID int64 `json:"id"` Name string `json:"name"` Brand string `json:"brand"` PriceRupiah int64 `json:"price_rupiah"` } type errorResponse struct { Error string `json:"error"` } func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil || id <= 0 { writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid product id"}) return } product, err := h.service.GetByID(r.Context(), id) if errors.Is(err, ErrNotFound) { writeJSON(w, http.StatusNotFound, errorResponse{Error: "product not found"}) return } if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "internal server error"}) return } writeJSON(w, http.StatusOK, productResponse{ ID: product.ID, Name: product.Name, Brand: product.Brand, PriceRupiah: product.PriceRupiah, }) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) }

Sekarang test handlernya. Test ini tidak menjalankan server, tidak membuat koneksi database, dan tidak butuh repository.

internal/product/handler_test.go
package product import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" ) type mockProductService struct { getByIDFunc func(ctx context.Context, id int64) (Product, error) } func (m mockProductService) GetByID(ctx context.Context, id int64) (Product, error) { return m.getByIDFunc(ctx, id) } func TestProductHandler_GetByID(t *testing.T) { tests := []struct { name string path string service mockProductService wantStatus int wantID int64 wantBodyContains string }{ { name: "success", path: "/v1/products/42", service: mockProductService{ getByIDFunc: func(ctx context.Context, id int64) (Product, error) { if id != 42 { return Product{}, errors.New("unexpected id") } return Product{ ID: 42, Name: "Hydrating Toner 100ml", Brand: "Wardah", PriceRupiah: 35000, }, nil }, }, wantStatus: http.StatusOK, wantID: 42, }, { name: "not found", path: "/v1/products/404", service: mockProductService{ getByIDFunc: func(ctx context.Context, id int64) (Product, error) { return Product{}, ErrNotFound }, }, wantStatus: http.StatusNotFound, wantBodyContains: "product not found", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := NewHandler(tt.service).Routes() req := httptest.NewRequest(http.MethodGet, tt.path, nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Fatalf("status code = %d, want %d, body = %s", w.Code, tt.wantStatus, w.Body.String()) } if tt.wantStatus == http.StatusOK { var got productResponse if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response body: %v", err) } if got.ID != tt.wantID { t.Errorf("product id = %d, want %d", got.ID, tt.wantID) } } if tt.wantBodyContains != "" && !strings.Contains(w.Body.String(), tt.wantBodyContains) { t.Errorf("body = %q, want contains %q", w.Body.String(), tt.wantBodyContains) } }) } }
💡Kenapa t.Fatalf untuk status code?

Jika status code salah, bentuk body biasanya ikut salah. t.Fatalf menghentikan subtest agar assertion berikutnya tidak membaca JSON dengan format yang tidak sesuai.

08

Cek JSON Body dengan Unmarshal

String body cukup untuk error sederhana, tetapi JSON success sebaiknya dicek sebagai struct.

Response JSON adalah kontrak API. Jangan hanya cek status code lalu menganggap payload benar.

Untuk error kecil, strings.Contains(w.Body.String(), "product not found") masih masuk akal. Untuk success response, decode JSON ke struct agar field yang penting benar-benar teruji.

internal/product/handler_test.go
var got productResponse if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response body: %v", err) } if got.ID != 42 { t.Errorf("product id = %d, want 42", got.ID) } if got.PriceRupiah != 35000 { t.Errorf("price_rupiah = %d, want %d", got.PriceRupiah, 35000) }
📝Harga dalam rupiah utuh

Di seluruh proyek skincare, harga disimpan sebagai PriceRupiah int64 berisi rupiah satuan utuh, jadi 35000 berarti Rp35.000. Test JSON ikut memverifikasi kontrak ini agar frontend tidak salah membaca harga sebagai sen.

w.Body.String()

Cocok untuk cek pesan error singkat atau memastikan body tidak kosong.

json.Unmarshal

Cocok untuk cek kontrak JSON seperti id, name, brand, dan price_rupiah.

⚠️Jangan membandingkan JSON mentah terlalu ketat

Membandingkan string JSON penuh mudah rapuh karena urutan field, whitespace, dan newline. Decode ke struct agar test fokus ke kontrak data.

09

Menguji Endpoint Cart dan Order

Pola yang sama berlaku untuk request POST dengan body JSON.

Produk memakai GET dengan route param, sedangkan cart dan order memakai POST dengan body JSON, jadi kita perlu mengirim body dan cek status 201 Created atau 409 Conflict.

Handler cart menambah item ke keranjang user. Bedanya dengan produk, kita membaca body JSON request, bukan route param. Service tetap berupa interface kecil agar bisa diganti mock.

internal/cart/handler.go
package cart import ( "context" "encoding/json" "errors" "net/http" "github.com/kamu/skincare-backend/internal/shared" ) type addItemRequest struct { ProductID int64 `json:"product_id"` Quantity int `json:"quantity"` } type cartItemResponse struct { ProductID int64 `json:"product_id"` Quantity int `json:"quantity"` } type CartService interface { AddItem(ctx context.Context, userID, productID int64, qty int) (CartItem, error) } type Handler struct { service CartService } func (h *Handler) AddItem(w http.ResponseWriter, r *http.Request) { var req addItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { shared.WriteJSON(w, http.StatusBadRequest, shared.ErrorBody("invalid json body")) return } if req.Quantity <= 0 { shared.WriteJSON(w, http.StatusUnprocessableEntity, shared.ErrorBody("quantity must be positive")) return } userID := shared.UserIDFromContext(r.Context()) item, err := h.service.AddItem(r.Context(), userID, req.ProductID, req.Quantity) if errors.Is(err, shared.ErrNotFound) { shared.WriteJSON(w, http.StatusNotFound, shared.ErrorBody("product not found")) return } if err != nil { shared.WriteJSON(w, http.StatusInternalServerError, shared.ErrorBody("internal server error")) return } shared.WriteJSON(w, http.StatusCreated, cartItemResponse{ ProductID: item.ProductID, Quantity: item.Quantity, }) }

Test cart mengirim body JSON lewat strings.NewReader lalu memverifikasi status 201 dan isi response. Perhatikan kita set header Content-Type pada request, sama seperti client sungguhan.

internal/cart/handler_test.go
func TestCartHandler_AddItem(t *testing.T) { service := mockCartService{ addItemFunc: func(ctx context.Context, userID, productID int64, qty int) (CartItem, error) { return CartItem{ProductID: productID, Quantity: qty}, nil }, } body := `{"product_id": 42, "quantity": 2}` req := httptest.NewRequest(http.MethodPost, "/v1/cart/items", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() NewHandler(service).Routes().ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status = %d, want %d, body = %s", w.Code, http.StatusCreated, w.Body.String()) } var got cartItemResponse if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.Quantity != 2 { t.Errorf("quantity = %d, want 2", got.Quantity) } }

Order adalah checkout, jadi kasus menariknya adalah stok habis yang dipetakan ke 409 Conflict. Inilah contoh memetakan error domain ke status code lewat table test yang menjangkau ketiga domain.

internal/order/handler_test.go
func TestOrderHandler_Checkout(t *testing.T) { tests := []struct { name string svcErr error wantStatus int }{ {name: "created", svcErr: nil, wantStatus: http.StatusCreated}, {name: "stok habis", svcErr: shared.ErrConflict, wantStatus: http.StatusConflict}, {name: "cart kosong", svcErr: shared.ErrValidation, wantStatus: http.StatusUnprocessableEntity}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { service := mockOrderService{ checkoutFunc: func(ctx context.Context, userID int64) (Order, error) { if tt.svcErr != nil { return Order{}, tt.svcErr } return Order{ID: 1001, TotalRupiah: 70000}, nil }, } req := httptest.NewRequest(http.MethodPost, "/v1/orders", nil) req = req.WithContext(shared.WithUserID(req.Context(), 7)) w := httptest.NewRecorder() NewHandler(service).Routes().ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) } }) } }
💡Total order tetap rupiah utuh

Field TotalRupiah int64 bernilai 70000 berarti Rp70.000, konsisten dengan PriceRupiah di katalog. Hindari membuat tipe harga baru di handler test.

10

Header, Authorization, dan Context

Endpoint cart dan order butuh identitas user, dan itu datang dari header lalu context.

Banyak bug handler bukan soal logic, melainkan soal request tidak membawa header atau context yang benar.

Di proyek skincare, cart dan order milik user tertentu. User biasanya ditentukan dari token di header Authorization, lalu middleware menaruh user ID ke dalam r.Context(). Saat menguji handler langsung tanpa middleware, kita perlu menanam context itu sendiri.

internal/order/handler_test.go
req := httptest.NewRequest(http.MethodPost, "/v1/orders", nil) req.Header.Set("Authorization", "Bearer dummy-token") req = req.WithContext(shared.WithUserID(req.Context(), 7)) w := httptest.NewRecorder() handler.ServeHTTP(w, req)
r.WithContext(ctx)

Mengembalikan salinan request dengan context baru. Karena context.Context immutable, kita selalu memakai hasil kembaliannya, bukan memodifikasi request lama.

JS / PHP
  • Di Supertest, kamu menulis .set(‘Authorization’, token) pada request builder.
  • Di Laravel, kamu memakai actingAs($user) agar request seolah login.
Go
  • Di Go, kamu set header lewat req.Header.Set dan menanam user lewat req.WithContext.
  • Jika handler hanya baca user dari context, cukup tanam context tanpa header asli.
⚠️Context user wajib ada saat handler diuji tanpa middleware

Jika handler memanggil shared.UserIDFromContext tetapi test tidak menanam user, kamu bisa mendapat user ID nol dan status salah. Pastikan context request membawa identitas yang relevan.

11

Menguji Middleware lewat ServeHTTP

Middleware juga http.Handler, jadi ia diuji dengan teknik yang sama.

Middleware auth dan recover adalah pagar penting, dan keduanya bisa diuji langsung lewat ServeHTTP dengan handler tiruan di belakangnya.

Middleware auth membaca header Authorization, dan menolak request tanpa token dengan 401 Unauthorized. Kita uji dengan membungkus handler dummy lalu memeriksa apakah handler itu sempat terpanggil.

internal/middleware/auth_test.go
func TestRequireAuth(t *testing.T) { tests := []struct { name string authHeader string wantStatus int wantNext bool }{ {name: "tanpa token", authHeader: "", wantStatus: http.StatusUnauthorized, wantNext: false}, {name: "token valid", authHeader: "Bearer ok", wantStatus: http.StatusOK, wantNext: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { nextCalled := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/v1/orders", nil) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) } w := httptest.NewRecorder() RequireAuth(next).ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) } if nextCalled != tt.wantNext { t.Errorf("next called = %v, want %v", nextCalled, tt.wantNext) } }) } }
flowchart LR
  R["httptest Request<br/>+ Authorization header"] --> M{"RequireAuth<br/>middleware"}
  M -->|header kosong| U["401 Unauthorized<br/>next TIDAK terpanggil"]
  M -->|token valid| N["next handler<br/>200 OK, nextCalled true"]
  U --> Rec["ResponseRecorder<br/>assert Code dan nextCalled"]
  N --> Rec

Gambar 3. Menguji middleware berarti memeriksa dua hal sekaligus, status code yang ditulis dan apakah handler berikutnya sempat dijalankan.

Middleware recover sebaiknya mengubah panic di handler menjadi 500 Internal Server Error yang rapi, bukan crash. Kita uji dengan handler yang sengaja panic.

internal/middleware/recover_test.go
func TestRecover(t *testing.T) { panicky := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("boom saat checkout") }) req := httptest.NewRequest(http.MethodPost, "/v1/orders", nil) w := httptest.NewRecorder() Recover(panicky).ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d", w.Code, http.StatusInternalServerError) } }
🌉Jembatan: dari middleware Express dan Laravel

Di Express, kamu menguji middleware dengan memanggilnya bersama req, res, dan next tiruan. Di Laravel, kamu sering memakai HTTP test untuk route yang dilindungi. Di Go, middleware adalah http.Handler biasa, jadi ServeHTTP dengan recorder sudah cukup.

12

Menjalankan Handler Test

Perintahnya tetap memakai go test, sama seperti unit test logic murni.

Handler test berada di paket Go biasa, jadi seluruh tooling go test tetap berlaku.

Terminal
go test ./... go test -v ./internal/product ./internal/cart ./internal/order go test -run TestOrderHandler_Checkout ./internal/order go test -cover ./internal/cart

go test ./... menjalankan semua test di semua package, termasuk product, cart, order, dan middleware. go test -v menampilkan subtest seperti created, stok_habis, dan cart_kosong. go test -run TestOrderHandler_Checkout berguna saat kamu sedang memperbaiki satu handler. go test -cover memberi gambaran kasar berapa banyak branch handler yang sudah tersentuh.

📝Coverage bukan tujuan akhir

Coverage tinggi tidak otomatis berarti test bagus. Untuk handler, cek kasus behavior penting, misalnya success, input invalid, not found, unauthorized, dan conflict.

13

Jebakan Umum

Sebagian besar bug handler test muncul dari boundary HTTP yang terlalu dianggap sepele.

Handler test yang baik biasanya kecil, eksplisit, dan tidak mencampur lapisan data.

Kadang kamu ingin menguji satu method handler tanpa membangun router penuh, misalnya saat method memakai route param. Untuk itu, chi menyediakan cara menanam route param langsung ke context request.

internal/product/handler_test.go
import "github.com/go-chi/chi/v5" routeCtx := chi.NewRouteContext() routeCtx.URLParams.Add("id", "42") req := httptest.NewRequest(http.MethodGet, "/", nil) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) w := httptest.NewRecorder() NewHandler(service).GetByID(w, req)
📝Router penuh tetap lebih realistis

Menanam chi.RouteCtxKey berguna untuk test method yang sangat fokus, tetapi memanggil Routes().ServeHTTP menguji routing dan param sekaligus, jadi itu yang dipakai sebagai default di modul ini.

Memanggil method handler tanpa router

Jika handler memakai chi.URLParam, panggil router ServeHTTP atau tanam chi.RouteCtxKey, jangan biarkan param kosong.

Mock terlalu pintar

Mock cukup mengembalikan output yang dibutuhkan test. Jangan menaruh business logic baru di mock.

Lupa cek error JSON

Status 404 saja belum cukup. Client butuh body error yang stabil untuk ditampilkan atau diproses.

Test bergantung database

Untuk modul ini, database belum masuk. Database test akan dibahas di chapter integrasi.

🌉Jembatan: dari Jest mock ke Go interface

Di Jest kamu mungkin memakai jest.fn(). Di Go, fake sering berupa struct kecil yang memiliki method sesuai interface. Lebih eksplisit, lebih verbose sedikit, tetapi sangat mudah dibaca.

14

Hands-on Ringan

Tambahkan satu test untuk input invalid agar handler makin siap dipakai frontend.

Latihan kecil ini menjaga handler produk tidak menerima ID aneh dari URL.

Tambahkan case invalid id

Di table test, tambahkan path /v1/products/abc dengan status http.StatusBadRequest.

Pastikan service tidak dipanggil

Buat mock yang memanggil t.Fatalf jika GetByID terpanggil untuk ID invalid.

Cek body error

Pastikan body mengandung invalid product id agar client mendapat pesan yang stabil.

internal/product/handler_test.go
func TestProductHandler_GetByID_InvalidID(t *testing.T) { service := mockProductService{ getByIDFunc: func(ctx context.Context, id int64) (Product, error) { t.Fatalf("service should not be called for invalid id") return Product{}, nil }, } handler := NewHandler(service).Routes() req := httptest.NewRequest(http.MethodGet, "/v1/products/abc", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", w.Code, http.StatusBadRequest) } if !strings.Contains(w.Body.String(), "invalid product id") { t.Errorf("body = %q, want invalid product id", w.Body.String()) } }
💡Langkah berikutnya

Setelah handler GET produk aman, terapkan langkah serupa untuk satu test cart yang menolak quantity nol dan satu test order yang memetakan stok habis ke 409.

15

Ringkasan & Poin Penting

Handler test adalah pagar pertama agar kontrak API online shop skincare tetap stabil saat service dan repository berkembang.

Yang Wajib Menempel

  • httptest.NewRequest() membuat request test untuk handler, bukan request client biasa.
  • httptest.NewRecorder() menggantikan http.ResponseWriter dan menyimpan status code, header, serta body.
  • handler.ServeHTTP(w, r) menjalankan router atau handler langsung di memori tanpa membuka port.
  • w.Code dipakai untuk cek status code, sedangkan w.Body.String() atau json.Unmarshal dipakai untuk cek response body.
  • Mock service dengan interface menjaga handler test tetap fokus pada HTTP behavior, bukan database.
  • Untuk route chi yang memakai parameter URL, panggil router agar chi.URLParam terisi, atau tanam chi.RouteCtxKey untuk test method tunggal.
  • Endpoint cart dan order memakai POST dengan body JSON, di-set Content-Type, lalu dicek status 201, 409, dan 422.
  • Identitas user ditanam lewat header Authorization dan req.WithContext agar handler cart dan order tahu siapa pemiliknya.
  • Middleware auth dan recover adalah http.Handler, jadi diuji lewat ServeHTTP dengan handler dummy sambil mengecek apakah next terpanggil.
  • Harga tetap PriceRupiah int64 rupiah utuh di sepanjang test, mis. 35000 berarti Rp35.000.
  • Langkah berikutnya adalah memperluas test ke service yang memakai mock repository, lalu masuk ke integration test dengan PostgreSQL test database.

Progress disimpan lokal di browser ini.