golang-gin-testing
golang-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
TestMainfor shared test infrastructure
Table of Contents
- Testing Philosophy
- Test Helpers
- Handler Tests with httptest
- Table-Driven Handler Tests
- Service Tests with Mocked Repository
- Running Tests
- Reference Files
- 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 (
"context"
"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",
})
// Use errors.As to unwrap *AppError and inspect the HTTP status code.
// errors.Is works only if AppError implements Is(); errors.As is always safe.
var appErr *domain.AppError
if !errors.As(err, &appErr) || appErr.Code != 409 {
t.Errorf("expected ErrConflict (409 AppError), 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, benchmark tests (BenchmarkX), fuzz tests (FuzzX), golden file/snapshot testing, test organization (same-package vs external-package, build tags), testify assertions - references/integration-tests.md — testcontainers-go setup,
TestMainfor 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 golang-gin-api skill
- For
UserRepositoryinterface and GORM/sqlx implementations: see the golang-gin-database skill - For JWT middleware and auth handler test patterns: see the golang-gin-auth skill
- golang-gin-clean-arch → Architecture: mock strategy (boundaries only), testing by layer, test fixtures
Official Docs
If this skill doesn't cover your use case, consult the Go testing package, httptest GoDoc, or testcontainers-go docs.
More from cylixlee/cortex
eino-adk
Eino Agent Development Kit development skill. For building AI Agent applications including ChatModelAgent, workflows (Sequential/Parallel/Loop), multi-agent systems (Supervisor/PlanExecute), human-in-the-loop (interruption/approval). Use when users need to create Agents, use Runner for execution, manage tool calls, build multi-agent systems.
2pnpm
Node.js package manager with strict dependency resolution. Use when running pnpm specific commands, configuring workspaces, or managing dependencies with catalogs, patches, or overrides.
2vue-debug-guides
Vue 3 debugging and error handling for runtime errors, warnings, async failures, and SSR/hydration issues. Use when diagnosing or fixing Vue issues.
2frontend-design
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
2golang-gin-api
Build REST APIs with Go Gin framework. Covers routing, handler patterns, request binding/validation, middleware chains, error handling, security headers (OWASP), CORS, timeout middleware, and layered project structure. Use when creating Go web servers, REST endpoints, HTTP handlers, or working with the Gin framework. Also activate when the user mentions Gin routes, middleware, JSON responses, request parsing, or API structure in Go.
2design-pattern
Applies object-oriented design principles and design patterns to generate maintainable, extensible code. Use when generating code that requires proper architectural layering, SOLID principles, and appropriate design patterns to solve recurring software design problems.
2