gin-testing

SKILL.md

gin-testing — Testing REST APIs

Write confident tests for Gin APIs: unit tests with mocked repositories, integration tests with real PostgreSQL via testcontainers, and e2e tests for critical flows. This skill covers the 80% of testing patterns you need daily.

When to Use

  • Writing tests for Gin handlers (UserHandler, AuthHandler)
  • Testing services with a mocked UserRepository
  • Setting up integration tests with a real database (testcontainers)
  • Testing JWT auth middleware in isolation
  • Adding table-driven tests for request validation and error paths
  • Setting up TestMain for shared test infrastructure

Table of Contents

  1. Testing Philosophy
  2. Test Helpers
  3. Handler Tests with httptest
  4. Table-Driven Handler Tests
  5. Service Tests with Mocked Repository
  6. Running Tests
  7. Reference Files
  8. Cross-Skill References

Testing Philosophy

Layer Tool Goal
Handler httptest + mock service Verify HTTP contract (status codes, JSON shape)
Service mock repository Verify business logic, error mapping
Repository testcontainers (real DB) Verify SQL correctness
E2E running server + real DB Verify critical user flows end-to-end

Unit tests run fast and cover most cases. Integration tests verify real database behavior. E2e tests catch wiring bugs. Never mock what you're testing — mock the layer below.


Test Helpers

Define reusable helpers in internal/testutil/ to keep tests DRY.

// internal/testutil/helpers.go
package testutil

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gin-gonic/gin"
)

func init() {
    // Suppress Gin debug output in tests
    gin.SetMode(gin.TestMode)
}

// NewTestRouter creates a bare Gin engine for tests (no Logger/Recovery noise).
func NewTestRouter() *gin.Engine {
    return gin.New()
}

// PerformRequest executes an HTTP request against the given router and returns the recorder.
func PerformRequest(t *testing.T, router *gin.Engine, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
    t.Helper()

    var reqBody *bytes.Buffer
    if body != nil {
        b, err := json.Marshal(body)
        if err != nil {
            t.Fatalf("PerformRequest: failed to marshal body: %v", err)
        }
        reqBody = bytes.NewBuffer(b)
    } else {
        reqBody = bytes.NewBuffer(nil)
    }

    req, err := http.NewRequest(method, path, reqBody)
    if err != nil {
        t.Fatalf("PerformRequest: failed to create request: %v", err)
    }

    if body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
    for k, v := range headers {
        req.Header.Set(k, v)
    }

    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

// AssertJSON unmarshals the recorder body into dst, failing the test on error.
func AssertJSON(t *testing.T, w *httptest.ResponseRecorder, dst any) {
    t.Helper()
    if err := json.Unmarshal(w.Body.Bytes(), dst); err != nil {
        t.Fatalf("AssertJSON: failed to unmarshal %q: %v", w.Body.String(), err)
    }
}

// BearerHeader returns an Authorization header map for JWT-protected routes.
func BearerHeader(token string) map[string]string {
    return map[string]string{"Authorization": "Bearer " + token}
}

Handler Tests with httptest

Test handlers by wiring a real router with a mock service, then using httptest.NewRecorder + router.ServeHTTP.

// internal/handler/user_handler_test.go
package handler_test

import (
    "context"
    "log/slog"
    "net/http"
    "testing"
    "time"

    "github.com/gin-gonic/gin"
    "myapp/internal/domain"
    "myapp/internal/handler"
    "myapp/internal/service"
    "myapp/internal/testutil"
)

// mockUserService implements service.UserService for tests.
type mockUserService struct {
    createFn  func(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error)
    getByIDFn func(ctx context.Context, id string) (*domain.User, error)
}

func (m *mockUserService) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) {
    return m.createFn(ctx, req)
}

func (m *mockUserService) GetByID(ctx context.Context, id string) (*domain.User, error) {
    return m.getByIDFn(ctx, id)
}

func setupUserRouter(svc service.UserService) *gin.Engine {
    r := testutil.NewTestRouter()
    h := handler.NewUserHandler(svc, slog.Default())
    r.POST("/users", h.Create)
    r.GET("/users/:id", h.GetByID)
    return r
}

func TestUserHandler_Create_Success(t *testing.T) {
    now := time.Now()
    svc := &mockUserService{
        createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) {
            return &domain.User{
                ID:        "user-123",
                Name:      req.Name,
                Email:     req.Email,
                Role:      "user",
                CreatedAt: now,
                UpdatedAt: now,
            }, nil
        },
    }

    router := setupUserRouter(svc)
    body := map[string]any{
        "name":     "Alice",
        "email":    "alice@example.com",
        "password": "secret123",
    }

    w := testutil.PerformRequest(t, router, http.MethodPost, "/users", body, nil)

    if w.Code != http.StatusCreated {
        t.Errorf("expected status 201, got %d; body: %s", w.Code, w.Body)
    }

    var got domain.User
    testutil.AssertJSON(t, w, &got)
    if got.ID != "user-123" {
        t.Errorf("expected user ID 'user-123', got %q", got.ID)
    }
}

func TestUserHandler_GetByID_NotFound(t *testing.T) {
    svc := &mockUserService{
        getByIDFn: func(ctx context.Context, id string) (*domain.User, error) {
            return nil, domain.ErrNotFound
        },
    }

    router := setupUserRouter(svc)
    w := testutil.PerformRequest(t, router, http.MethodGet, "/users/missing-id", nil, nil)

    if w.Code != http.StatusNotFound {
        t.Errorf("expected status 404, got %d", w.Code)
    }
}

Table-Driven Handler Tests

Table-driven tests cover all request variants (valid, invalid, edge cases) in one function.

// internal/handler/user_handler_table_test.go
package handler_test

import (
    "net/http"
    "testing"

    "myapp/internal/domain"
    "myapp/internal/testutil"
)

func TestUserHandler_Create_Validation(t *testing.T) {
    svc := &mockUserService{
        createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.User, error) {
            return &domain.User{ID: "1", Name: req.Name, Email: req.Email, Role: "user"}, nil
        },
    }
    router := setupUserRouter(svc)

    tests := []struct {
        name       string
        body       any
        wantStatus int
    }{
        {
            name:       "valid request",
            body:       map[string]any{"name": "Alice", "email": "alice@example.com", "password": "secret123"},
            wantStatus: http.StatusCreated,
        },
        {
            name:       "missing email",
            body:       map[string]any{"name": "Alice", "password": "secret123"},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "invalid email format",
            body:       map[string]any{"name": "Alice", "email": "not-an-email", "password": "secret123"},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "name too short",
            body:       map[string]any{"name": "A", "email": "alice@example.com", "password": "secret123"},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "password too short",
            body:       map[string]any{"name": "Alice", "email": "alice@example.com", "password": "short"},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "invalid role value",
            body:       map[string]any{"name": "Alice", "email": "alice@example.com", "password": "secret123", "role": "superadmin"},
            wantStatus: http.StatusBadRequest,
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            w := testutil.PerformRequest(t, router, http.MethodPost, "/users", tc.body, nil)
            if w.Code != tc.wantStatus {
                t.Errorf("want status %d, got %d; body: %s", tc.wantStatus, w.Code, w.Body)
            }
        })
    }
}

Service Tests with Mocked Repository

Test business logic without touching the database. The mock implements domain.UserRepository.

// internal/service/user_service_test.go
package service_test

import (
    "context"
    "errors"
    "log/slog"
    "testing"

    "myapp/internal/domain"
    "myapp/internal/service"
)

// mockUserRepository implements domain.UserRepository for service tests.
type mockUserRepository struct {
    createFn     func(ctx context.Context, user *domain.User) error
    getByEmailFn func(ctx context.Context, email string) (*domain.User, error)
    getByIDFn    func(ctx context.Context, id string) (*domain.User, error)
}

func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error {
    return m.createFn(ctx, user)
}
func (m *mockUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
    return m.getByEmailFn(ctx, email)
}
func (m *mockUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
    if m.getByIDFn != nil {
        return m.getByIDFn(ctx, id)
    }
    return nil, domain.ErrNotFound
}
func (m *mockUserRepository) List(ctx context.Context, opts domain.ListOptions) ([]domain.User, int64, error) {
    return nil, 0, nil
}
func (m *mockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *mockUserRepository) Delete(ctx context.Context, id string) error         { return nil }

func TestUserService_Create_DuplicateEmail(t *testing.T) {
    repo := &mockUserRepository{
        getByEmailFn: func(ctx context.Context, email string) (*domain.User, error) {
            return &domain.User{Email: email}, nil // email already taken
        },
        createFn: func(ctx context.Context, user *domain.User) error {
            return nil
        },
    }

    svc := service.NewUserService(repo, slog.Default())
    _, err := svc.Create(context.Background(), domain.CreateUserRequest{
        Name:     "Alice",
        Email:    "alice@example.com",
        Password: "secret123",
    })

    if !errors.Is(err, domain.ErrConflict) {
        t.Errorf("expected ErrConflict, got %v", err)
    }
}

Running Tests

# All tests with race detector and coverage
go test -v -race -cover ./...

# Specific package
go test -v -race ./internal/handler/...

# Run only unit tests (exclude integration)
go test -v -race -cover -tags='!integration' ./...

# Run integration tests only (requires Docker)
go test -v -race -tags=integration ./internal/repository/...

# Coverage report
go test -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Reference Files

Load these when you need deeper detail:

  • references/unit-tests.md — Handler httptest patterns, testing authenticated routes with mock JWT, middleware isolation tests, mock generation with gomock/manual mocks, t.Helper/t.Cleanup/t.Parallel, test fixtures and factories
  • references/integration-tests.md — testcontainers-go setup, TestMain for DB lifecycle, repository integration tests, cleanup between tests, build tags, fixture loading
  • references/e2e.md — End-to-end flow testing (register → login → CRUD), docker-compose test setup, GitHub Actions CI/CD, environment configuration, cleanup and idempotency

Cross-Skill References

  • For handler and service implementations being tested: see the gin-api skill
  • For UserRepository interface and GORM/sqlx implementations: see the gin-database skill
  • For JWT middleware and auth handler test patterns: see the gin-auth skill
Weekly Installs
2
GitHub Stars
1
First Seen
14 days ago
Installed on
opencode2
gemini-cli2
claude-code2
github-copilot2
codex2
kimi-cli2