testing

testing go handlers

how i wrote tests for gin handlers — mocks, httptest, cookies, jwts, mistakes i made

the mental model

our handler does this:

copy
HTTP Request bind JSON call DB sign JWT set cookie HTTP Response

a test controls the input, fakes the dependencies, asserts the output. no real server, no real postgres.

copy
we control what JSON the request sends
we fake what the DB returns
we assert status code, response body, cookies

why an interface

if our handler takes *dbgen.Queries directly we can't fake it. define an interface instead:

copy
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:

copy
// before
func RegisterUser(queries *dbgen.Queries, cfg *config.Config) gin.HandlerFunc
 
// after
func RegisterUser(queries repository.AuthRepository, cfg *config.Config) gin.HandlerFunc

file structure

tests live next to the file they test:

copy
internal/
  handlers/
    auth/
      register.go
      register_test.go
      login.go
      login_test.go
      logout.go
      logout_test.go
      helpers_test.go shared mock + helpers

helpers_test.go

everything shared lives here — mock struct, fake data, helper fns:

copy
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 methods

fake data helpers — always include a real bcrypt hash so login tests don't fail at password comparison:

copy
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:

copy
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:

copy
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:

copy
// 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
}

writing tests — the process

step 1 — trace every if err != nil in our handler:

copy
bind json fails 400
createUser 23505 400 user exists
createUser other err 500
sign token fails 500
everything works 200 + cookies

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

what the mock actually does

the mock ignores all inputs and returns whatever we tell it:

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

how httptest works

copy
// 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.SetCookie

router.ServeHTTP is gin's standard http.Handler interface — we're skipping the real network and calling it directly:

copy
main.go:  real network http.Server loop router.ServeHTTP handler
tests:    we router.ServeHTTP handler

the handler has no idea which path triggered it.

asserting the response

copy
// 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 wc.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.

sending cookies in requests

handlers that read from cookie (logout, refresh) need the cookie on the request — not the response:

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

handlers that verify JWTs

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():

copy
// 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.

mistakes i made

nil pointer panic — forgot to set a mock fn that the handler reaches on the success path:

copy
panic: runtime error: invalid memory address or nil pointer dereference
mockAuthRepo.CreateRefreshToken

fix — 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:

copy
// 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:

copy
// 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:

copy
// 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
    },
}

running tests

copy
# 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 ./...