how i wrote tests for gin handlers — mocks, httptest, cookies, jwts, mistakes i made
our handler does this:
HTTP Request → bind JSON → call DB → sign JWT → set cookie → HTTP Responsea test controls the input, fakes the dependencies, asserts the output. no real server, no real postgres.
we control → what JSON the request sends
we fake → what the DB returns
we assert → status code, response body, cookiesif our handler takes *dbgen.Queries directly we can't fake it. define an interface instead:
type AuthRepository interface {
CreateUser(ctx context.Context, params dbgen.CreateUserParams) (dbgen.User, error)
CreateRefreshToken(ctx context.Context, params dbgen.CreateRefreshTokenParams) (dbgen.RefreshToken, error)
GetUserByEmail(ctx context.Context, email string) (dbgen.User, error)
GetRefreshToken(ctx context.Context, hash string) (dbgen.RefreshToken, error)
DeleteRefreshToken(ctx context.Context, hash string) error
}*dbgen.Queries already satisfies this — go interfaces are implicit. change our handler to accept the interface:
// before
func RegisterUser(queries *dbgen.Queries, cfg *config.Config) gin.HandlerFunc
// after
func RegisterUser(queries repository.AuthRepository, cfg *config.Config) gin.HandlerFunctests live next to the file they test:
internal/
handlers/
auth/
register.go
register_test.go
login.go
login_test.go
logout.go
logout_test.go
helpers_test.go ← shared mock + helperseverything shared lives here — mock struct, fake data, helper fns:
package authHandler_test
type mockAuthRepo struct {
createUserFn func(ctx context.Context, params dbgen.CreateUserParams) (dbgen.User, error)
createRefreshTokenFn func(ctx context.Context, params dbgen.CreateRefreshTokenParams) (dbgen.RefreshToken, error)
getUserByEmailFn func(ctx context.Context, email string) (dbgen.User, error)
getRefreshTokenFn func(ctx context.Context, hash string) (dbgen.RefreshToken, error)
deleteRefreshTokenFn func(ctx context.Context, hash string) error
}
// implement each interface method — just calls the fn
func (m *mockAuthRepo) CreateUser(ctx context.Context, params dbgen.CreateUserParams) (dbgen.User, error) {
return m.createUserFn(ctx, params)
}
// ... rest of the methodsfake data helpers — always include a real bcrypt hash so login tests don't fail at password comparison:
func fakeUser() dbgen.User {
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
return dbgen.User{
ID: userID,
Name: "suprim",
Email: "suprim@example.com",
PasswordHash: string(hash),
Role: "customer",
}
}generate a real signed JWT for tests that verify tokens:
func generateRefreshToken(userID string) string {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, _ := token.SignedString([]byte("test-refresh-secret"))
return signed
}setup router — each test file calls this with its own route:
func setupRouter(register func(r *gin.Engine)) *gin.Engine {
gin.SetMode(gin.TestMode)
validator.Init()
r := gin.New()
register(r)
return r
}make request helpers:
// simple request — builds and fires in one shot
func makeRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(method, path, bytes.NewBuffer(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// request with cookie — returns *http.Request so we fire it ourself
func makeRequestWithCookie(t *testing.T, method, path string, body any, cookieName, cookieValue string) *http.Request {
t.Helper()
var buf *bytes.Buffer
if body != nil {
raw, _ := json.Marshal(body)
buf = bytes.NewBuffer(raw)
} else {
buf = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(method, path, buf)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: cookieName, Value: cookieValue})
return req
}step 1 — trace every if err != nil in our handler:
bind json fails → 400
createUser 23505 → 400 user exists
createUser other err → 500
sign token fails → 500
everything works → 200 + cookieseach line is one test.
step 2 — write success first, then one test per failure branch.
step 3 — keep everything ideal except the one thing the test is named after.
the mock ignores all inputs and returns whatever we tell it:
getUserByEmailFn: func(_ context.Context, _ string) (dbgen.User, error) {
return fakeUser(), nil // always returns this, doesn't care what email we sent
},those _ underscores mean "ignoring this". the mock doesn't look at the email — it just returns fakeUser() unconditionally. so in a wrong password test the email in the request body is irrelevant — only the password matters because that's what bcrypt checks against the hash.
// fake request — no real network
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(raw))
// fake response writer — records everything the handler writes
w := httptest.NewRecorder()
// run the handler as if a real request came in
router.ServeHTTP(w, req)
// now inspect what the handler wrote
w.Code // status code
w.Body.String() // response body JSON
w.Result().Cookies() // cookies set via c.SetCookierouter.ServeHTTP is gin's standard http.Handler interface — we're skipping the real network and calling it directly:
main.go: real network → http.Server loop → router.ServeHTTP → handler
tests: we → → router.ServeHTTP → handlerthe handler has no idea which path triggered it.
// status code
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d — body: %s", w.Code, w.Body.String())
}
// response body — unmarshal back into our response struct
var resp types.APIResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Success {
t.Errorf("expected success=true")
}
// cookies
var hasAccess, hasRefresh bool
for _, c := range w.Result().Cookies() {
if c.Name == "access_token" { hasAccess = true }
if c.Name == "refresh_token" { hasRefresh = true }
}
if !hasAccess { t.Errorf("access_token cookie missing") }
if !hasRefresh { t.Errorf("refresh_token cookie missing") }why unmarshal — w.Body is raw JSON bytes. we can't do w.Body.Success. unmarshal converts it back into a struct so we can check fields.
why cookies are in w — c.SetCookie in gin writes a Set-Cookie header into the response. w captures all headers the handler writes, w.Result().Cookies() parses those headers into []*http.Cookie.
handlers that read from cookie (logout, refresh) need the cookie on the request — not the response:
req := makeRequestWithCookie(t, "POST", "/auth/logout", nil, "refresh_token", generateRefreshToken(userID))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)makeRequestWithCookie returns *http.Request not *httptest.ResponseRecorder — because we need to build the request first, attach the cookie, then fire it ourself.
if our handler verifies the JWT signature (logout, refresh), the token in the cookie must be signed with the same secret we use in testConfig():
// testConfig uses this secret
func testConfig() *config.Config {
return &config.Config{
JWTRefreshSecret: "test-refresh-secret",
}
}
// generateRefreshToken signs with the same secret
func generateRefreshToken(userID string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{...})
signed, _ := token.SignedString([]byte("test-refresh-secret"))
return signed
}if secrets don't match, JWT verification fails and we never reach the DB — test fails for the wrong reason.
nil pointer panic — forgot to set a mock fn that the handler reaches on the success path:
panic: runtime error: invalid memory address or nil pointer dereference
mockAuthRepo.CreateRefreshTokenfix — trace how far the handler executes for this scenario and set every fn along that path.
missing password in request body — sent only email in login test, binding failed before hitting any logic:
// wrong
makeRequest(t, router, "POST", "/auth/login", map[string]string{
"email": "suprim@example.com",
// password missing → binding fails → 400 before anything runs
})
// correct
makeRequest(t, router, "POST", "/auth/login", map[string]string{
"email": "suprim@example.com",
"password": "secret123",
})fakeUser had no password hash — bcrypt.CompareHashAndPassword("", "secret123") always fails:
// wrong
return dbgen.User{Email: "suprim@example.com"} // empty PasswordHash
// correct
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
return dbgen.User{Email: "suprim@example.com", PasswordHash: string(hash)}dead code in mock — setting a mock fn that the handler never reaches:
// handler returns early on invalid JWT, never hits DB
// this fn is never called — dead code
repo := &mockAuthRepo{
deleteRefreshTokenFn: func(...) error {
return fmt.Errorf("some error")
},
}
// better — use t.Fatal to enforce it's never called
repo := &mockAuthRepo{
deleteRefreshTokenFn: func(...) error {
t.Fatal("should not reach deleteRefreshToken with invalid token")
return nil
},
}# run everything
go test ./...
# specific package, verbose
go test -v ./internal/handlers/auth/...
# specific test by name
go test -v -run ^TestRegisterUser_Success$ ./internal/handlers/auth/...
# coverage
go test -cover ./...