This episode tests a Gin application properly: unit testing handlers with httptest, gin.CreateTestContext for testing handlers directly, table-driven tests, mocking services with interfaces, and benchmarking routes with testing.B.

Untested code is a ticking time bomb. This episode 17 dissects testing & benchmark for a Gin application: testing endpoints with httptest, testing handlers in isolation with gin.CreateTestContext, writing elegant table-driven tests, mocking services with the interfaces from episode 8, and measuring performance with testing.B.
Unlike manual testing with curl, automated tests can run in the CI pipeline every time the code changes. The layered structure you built in episode 8 pays off here: because handlers receive services through interfaces, swapping the real service for a mock is trivial.
The most direct way: create an engine with the route under test, then send a real request:
func setupRouter() *gin.Engine {
r := gin.New()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})
return r
}
func TestPing(t *testing.T) {
r := setupRouter()
req := httptest.NewRequest("GET", "/ping", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("kode status: got %d, want %d", w.Code, http.StatusOK)
}
}httptest.NewRequest("GET", "/ping", nil) creates a real request without a network, and httptest.NewRecorder() captures the response. r.ServeHTTP(w, req) runs the entire middleware and handler stack. This pattern tests the full stack — including CORS or authentication middleware.
Sometimes only the handler is tested, without the router. gin.CreateTestContext builds a context directly:
func TestGetUserHandler(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/users/7", nil)
c.Params = gin.Params{{Key: "id", Value: "7"}}
handler := NewHandler(stubService())
handler.GetUser(c)
if w.Code != http.StatusOK {
t.Fatalf("kode status: got %d, want %d", w.Code, http.StatusOK)
}
}gin.CreateTestContext(w) returns a context ready for the handler. Path parameters are filled in via c.Params, and c.Request is set to a fake request. This approach is the fastest way to test a single handler without other middleware.
A table-driven test writes many cases as a slice of structs, then runs them in one loop:
func TestGetUserHandler(t *testing.T) {
tests := []struct {
name string
id string
want int
}{
{"id valid", "1", 200},
{"id bukan angka", "abc", 400},
{"user tidak ada", "999", 404},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/users/"+tt.id, nil)
c.Params = gin.Params{{Key: "id", Value: tt.id}}
NewHandler(fakeRepo()).GetUser(c)
if w.Code != tt.want {
t.Errorf("kasus %s: got %d, want %d", tt.name, w.Code, tt.want)
}
})
}
}t.Run(tt.name, ...) runs each case as a subtest whose name appears in the output on failure. Adding a new case is just adding a row to the tests slice — no need to duplicate the test block.
Episode 8 established that services are defined as interfaces. That's the key to mocking: define a mock that implements the same contract, then inject it into the handler during testing.
type mockUserService struct {
user *User
err error
}
func (m *mockUserService) GetUser(ctx context.Context, id int64) (*User, error) {
return m.user, m.err
}
func stubService() UserService {
return &mockUserService{
user: &User{ID: 7, Name: "Arman"},
}
}mockUserService.GetUser returns predetermined data without touching the database. Each test sets user and err to simulate success or failure. Because the handler only knows the interface, it can't tell the mock from the real implementation.
To measure endpoint performance, write a function whose name starts with Benchmark:
func BenchmarkPing(b *testing.B) {
r := setupRouter()
req := httptest.NewRequest("GET", "/ping", nil)
w := httptest.NewRecorder()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
r.ServeHTTP(w, req)
}
}b.ReportAllocs() records memory allocations per operation. The b.N loop is adjusted automatically by Go until the benchmark completes with stable timing. Note the recorder w is reused — that's enough to measure routing and rendering.
go test ./...
go test -run=NONE -bench=. -benchmem ./internal/userThe command go test -run=NONE -bench=. -benchmem ./internal/user runs only benchmarks (not tests) with allocation reporting. Output like 27364 ns/op and 0 B/op gives a picture of latency and memory efficiency per request. Episode 19 will use these numbers for optimization.
Key takeaways:
httptest.NewRequest + NewRecorder test endpoints without a network.gin.CreateTestContext tests handlers in isolation.Benchmark with b.ReportAllocs measures ns/op and B/op.go test ./... in CI so regressions are caught early.In the next episode, episode 18, we'll dissect observability & monitoring — Prometheus metrics with prometheus/client_golang, OpenTelemetry tracing with otelgin, structured logs, and health check endpoints for Kubernetes integration.