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.
Testing HTTP Handler
dengan httptest
Uji perilaku API Go tanpa membuka port, tanpa menjalankan server sungguhan, dan tanpa menyentuh database.
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.
/v1/products/{id} Ambil detail produk skincare berdasarkan ID produk /v1/cart/items Tambah item ke cart milik user yang sedang login /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.
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.
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.
Membuat *http.Request server-side untuk test, misalnya request GET /v1/products/42 dengan body nil atau JSON.
Membuat *httptest.ResponseRecorder, pengganti http.ResponseWriter yang menyimpan status code, header, dan body yang ditulis handler.
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.
- Di Jest atau Supertest, kamu sering menulis
expect(response.status).toBe(200). - Di Laravel, kamu sering menulis
assertStatus(200)danassertJson().
- Di Go, kamu cek
w.Codeuntuk status danw.Body.String()ataujson.Unmarshaluntuk response. - Dependency luar diganti dengan mock kecil yang memenuhi interface service.
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.
- 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.
Accept interfaces, return structs. Handler menerima interface service agar test bisa memberi fake, sedangkan service nyata tetap mengembalikan struct domain yang jelas.
NewRecorder dan NewRequest
Dua object ini adalah pasangan utama di handler test.
NewRequest membuat input, NewRecorder menangkap output.
internal/product/handler_test.goreq := 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.
w.Code bisa 0Jika 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).
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.gohandler := 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.
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.
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.gotype 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.gotype 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) }
Mock manual cocok untuk handler dan service kecil. Saat interface mulai besar, itu sering menjadi sinyal desain bahwa interface terlalu lebar untuk kebutuhan handler.
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.gopackage 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.gopackage 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) } }) } }
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.
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.govar 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) }
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.
Membandingkan string JSON penuh mudah rapuh karena urutan field, whitespace, dan newline. Decode ke struct agar test fokus ke kontrak data.
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.gopackage 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.gofunc 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.gofunc 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) } }) } }
Field TotalRupiah int64 bernilai 70000 berarti Rp70.000, konsisten dengan PriceRupiah di katalog. Hindari membuat tipe harga baru di handler test.
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.goreq := 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)
Mengembalikan salinan request dengan context baru. Karena context.Context immutable, kita selalu memakai hasil kembaliannya, bukan memodifikasi request lama.
- Di Supertest, kamu menulis
.set(‘Authorization’, token)pada request builder. - Di Laravel, kamu memakai
actingAs($user)agar request seolah login.
- Di Go, kamu set header lewat
req.Header.Setdan menanam user lewatreq.WithContext. - Jika handler hanya baca user dari context, cukup tanam context tanpa header asli.
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.
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.gofunc 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 --> RecGambar 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.gofunc 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) } }
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.
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.
Terminalgo 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 tinggi tidak otomatis berarti test bagus. Untuk handler, cek kasus behavior penting, misalnya success, input invalid, not found, unauthorized, dan conflict.
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.goimport "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)
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.
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.
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.
Di table test, tambahkan path /v1/products/abc dengan status http.StatusBadRequest.
Buat mock yang memanggil t.Fatalf jika GetByID terpanggil untuk ID invalid.
Pastikan body mengandung invalid product id agar client mendapat pesan yang stabil.
internal/product/handler_test.gofunc 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()) } }
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.
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()menggantikanhttp.ResponseWriterdan menyimpan status code, header, serta body.handler.ServeHTTP(w, r)menjalankan router atau handler langsung di memori tanpa membuka port.w.Codedipakai untuk cek status code, sedangkanw.Body.String()ataujson.Unmarshaldipakai 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.URLParamterisi, atau tanamchi.RouteCtxKeyuntuk test method tunggal. - Endpoint cart dan order memakai POST dengan body JSON, di-set
Content-Type, lalu dicek status201,409, dan422. - Identitas user ditanam lewat header
Authorizationdanreq.WithContextagar handler cart dan order tahu siapa pemiliknya. - Middleware auth dan recover adalah
http.Handler, jadi diuji lewatServeHTTPdengan handler dummy sambil mengecek apakahnextterpanggil. - Harga tetap
PriceRupiah int64rupiah utuh di sepanjang test, mis.35000berarti 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.