Learn Gin - Testing & Benchmark
Series/Learn Gin/Episode 17
Episode 17 of 23

Learn Gin - Testing & Benchmark

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Unit Testing Handlers with httptest

Building a Small Engine

The most direct way: create an engine with the route under test, then send a real request:

Test a full endpoint
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.

gin.CreateTestContext

Testing a Handler in Isolation

Sometimes only the handler is tested, without the router. gin.CreateTestContext builds a context directly:

CreateTestContext
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.

Table-Driven Tests

Cases in a Single Loop

A table-driven test writes many cases as a slice of structs, then runs them in one loop:

Table-driven test
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.

Mocking Services

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.

Mock service
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.

Benchmarking with testing.B

Benchmarking a Route

To measure endpoint performance, write a function whose name starts with Benchmark:

Benchmark a route
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.

Running and Reading Results

Run tests and benchmarks
go test ./...
go test -run=NONE -bench=. -benchmem ./internal/user

The 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.

Closing

Key takeaways:

  • httptest.NewRequest + NewRecorder test endpoints without a network.
  • gin.CreateTestContext tests handlers in isolation.
  • Table-driven tests make new cases just an added row.
  • Interfaces from clean architecture make mocking services easy.
  • Benchmark with b.ReportAllocs measures ns/op and B/op.
  • Run 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.

Learn Gin - Testing & Benchmark | Learn Gin