一键导入
qa-go
Go testing skill with table-driven tests, httptest, testify, and integration testing patterns. Use when writing or running Go backend tests.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Go testing skill with table-driven tests, httptest, testify, and integration testing patterns. Use when writing or running Go backend tests.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Node.js + Express + MongoDB backend development for MEAN stack apps. Use when implementing server code: models, routes, controllers, middleware, config, and database connections.
Code review for MEAN stack apps. Use when reviewing backend (Express + MongoDB) and frontend (React) code for quality, security, architecture compliance, performance, and best practices.
Express.js API architecture for MEAN stack apps. Use when designing REST endpoints, middleware chains, authentication flows, error handling contracts, and request/response schemas.
React frontend development for MEAN stack apps. Use when implementing React components, pages, hooks, API integration, routing, state management, and responsive UI.
MongoDB database architecture for MEAN stack apps. Use when designing collections, schemas, indexes, relationships, and data modeling with Mongoose ODM.
Product management for MEAN stack apps. Use when writing PRDs, defining features, acceptance criteria, or reviewing deliverables for MongoDB + Express + React + Node.js projects.
| name | qa-go |
| description | Go testing skill with table-driven tests, httptest, testify, and integration testing patterns. Use when writing or running Go backend tests. |
This skill equips the tester-go agent with everything needed to write, run, and maintain Go backend tests in a fullstack Golang + React codebase. It covers unit testing for handlers, services, and repositories, integration testing with database mocks, HTTP handler testing with Chi router, and mock generation with testify. The guiding philosophy: test behavior at interface boundaries, not internal implementation details.
| Tool | Role | Import Path |
|---|---|---|
| testing | Standard library test runner | testing |
| testify/assert | Non-fatal assertions | github.com/stretchr/testify/assert |
| testify/require | Fatal assertions (stop test on failure) | github.com/stretchr/testify/require |
| testify/mock | Interface mocking | github.com/stretchr/testify/mock |
| testify/suite | Test suite with setup/teardown (optional) | github.com/stretchr/testify/suite |
| httptest | HTTP handler testing | net/http/httptest |
| sqlmock | SQL database mocking | github.com/DATA-DOG/go-sqlmock |
| testcontainers-go | Real database in Docker (integration) | github.com/testcontainers/testcontainers-go |
| go-chi | Router context for handler tests | github.com/go-chi/chi/v5 |
Every function with multiple scenarios MUST use table-driven tests. This is the single most important Go testing pattern.
func TestUserService_Create(t *testing.T) {
tests := []struct {
name string
input model.CreateUserRequest
mockFn func(*MockUserRepository)
wantErr bool
errType error
}{
{
name: "success - creates user with valid input",
input: model.CreateUserRequest{Email: "test@example.com", Name: "Test User"},
mockFn: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "test@example.com").Return(nil, repository.ErrNotFound)
m.On("Create", mock.Anything, mock.AnythingOfType("*model.User")).Return(nil)
},
wantErr: false,
},
{
name: "error - duplicate email returns conflict",
input: model.CreateUserRequest{Email: "exists@example.com", Name: "Test"},
mockFn: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "exists@example.com").Return(&model.User{}, nil)
},
wantErr: true,
errType: model.ErrAlreadyExists,
},
{
name: "error - empty email returns validation error",
input: model.CreateUserRequest{Email: "", Name: "Test"},
mockFn: func(m *MockUserRepository) {},
wantErr: true,
errType: model.ErrValidation,
},
{
name: "error - repository failure returns internal error",
input: model.CreateUserRequest{Email: "test@example.com", Name: "Test"},
mockFn: func(m *MockUserRepository) {
m.On("GetByEmail", mock.Anything, "test@example.com").Return(nil, repository.ErrNotFound)
m.On("Create", mock.Anything, mock.AnythingOfType("*model.User")).Return(fmt.Errorf("db connection failed"))
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := new(MockUserRepository)
tt.mockFn(repo)
svc := service.NewUserService(repo)
err := svc.Create(context.Background(), tt.input)
if tt.wantErr {
require.Error(t, err)
if tt.errType != nil {
assert.ErrorIs(t, err, tt.errType)
}
} else {
require.NoError(t, err)
}
repo.AssertExpectations(t)
})
}
}
name field is mandatory, must be descriptivet.Run(tt.name, ...) — enables running individual test casesmockFn field, not shared mock statemock.AssertExpectations(t) at the endfunc TestUserHandler_GetByID(t *testing.T) {
tests := []struct {
name string
userID string
mockFn func(*MockUserService)
wantStatus int
wantBody string
}{
{
name: "success - returns user",
userID: "550e8400-e29b-41d4-a716-446655440000",
mockFn: func(m *MockUserService) {
m.On("GetByID", mock.Anything, uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")).
Return(&model.User{
ID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"),
Name: "Test User",
Email: "test@example.com",
}, nil)
},
wantStatus: http.StatusOK,
wantBody: `"name":"Test User"`,
},
{
name: "error - invalid UUID format",
userID: "not-a-uuid",
mockFn: func(m *MockUserService) {},
wantStatus: http.StatusBadRequest,
wantBody: `"error"`,
},
{
name: "error - user not found",
userID: "550e8400-e29b-41d4-a716-446655440000",
mockFn: func(m *MockUserService) {
m.On("GetByID", mock.Anything, mock.Anything).
Return(nil, service.ErrNotFound)
},
wantStatus: http.StatusNotFound,
wantBody: `"error"`,
},
{
name: "error - internal server error",
userID: "550e8400-e29b-41d4-a716-446655440000",
mockFn: func(m *MockUserService) {
m.On("GetByID", mock.Anything, mock.Anything).
Return(nil, fmt.Errorf("database timeout"))
},
wantStatus: http.StatusInternalServerError,
wantBody: `"error"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := new(MockUserService)
tt.mockFn(svc)
h := handler.NewUserHandler(svc)
// Create request with Chi URL params
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/"+tt.userID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", tt.userID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.GetByID(rec, req)
assert.Equal(t, tt.wantStatus, rec.Code)
assert.Contains(t, rec.Body.String(), tt.wantBody)
svc.AssertExpectations(t)
})
}
}
func TestUserHandler_Create(t *testing.T) {
tests := []struct {
name string
body string
mockFn func(*MockUserService)
wantStatus int
}{
{
name: "success - creates user",
body: `{"email":"new@test.com","name":"New User","password":"securepass123"}`,
mockFn: func(m *MockUserService) {
m.On("Create", mock.Anything, mock.AnythingOfType("model.CreateUserRequest")).
Return(&model.User{ID: uuid.New(), Email: "new@test.com", Name: "New User"}, nil)
},
wantStatus: http.StatusCreated,
},
{
name: "error - malformed JSON",
body: `{invalid json`,
mockFn: func(m *MockUserService) {},
wantStatus: http.StatusBadRequest,
},
{
name: "error - missing required field",
body: `{"name":"No Email"}`,
mockFn: func(m *MockUserService) {},
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := new(MockUserService)
tt.mockFn(svc)
h := handler.NewUserHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users",
strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Create(rec, req)
assert.Equal(t, tt.wantStatus, rec.Code)
svc.AssertExpectations(t)
})
}
}
func TestUserRoutes(t *testing.T) {
svc := new(MockUserService)
h := handler.NewUserHandler(svc)
r := chi.NewRouter()
r.Route("/api/v1/users", func(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Get("/{id}", h.GetByID)
r.Put("/{id}", h.Update)
r.Delete("/{id}", h.Delete)
})
ts := httptest.NewServer(r)
defer ts.Close()
// Test through actual router — validates routing, middleware, and URL params
resp, err := http.Get(ts.URL + "/api/v1/users")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
For every interface in the repository or service package, create a corresponding mock:
// MockUserRepository implements repository.UserRepository
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.User), args.Error(1)
}
func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
args := m.Called(ctx, email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.User), args.Error(1)
}
func (m *MockUserRepository) Create(ctx context.Context, user *model.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) Update(ctx context.Context, user *model.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) Delete(ctx context.Context, id uuid.UUID) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockUserRepository) List(ctx context.Context, opts model.ListOptions) ([]*model.User, int, error) {
args := m.Called(ctx, opts)
if args.Get(0) == nil {
return nil, 0, args.Error(2)
}
return args.Get(0).([]*model.User), args.Int(1), args.Error(2)
}
AssertExpectations(t) — verifies all expected calls were mademock.Anything for context.Context — never match on specific context valuesmock.AnythingOfType("*model.User") when you don't care about the exact valueargs.Get(0) == nil before type-asserting pointer returnsfunc TestUserRepository_GetByID(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
repo := repository.NewUserRepository(db)
userID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
rows := sqlmock.NewRows([]string{"id", "email", "name", "created_at", "updated_at"}).
AddRow(userID, "test@example.com", "Test User", time.Now(), time.Now())
mock.ExpectQuery(`SELECT .+ FROM users WHERE id = \$1`).
WithArgs(userID).
WillReturnRows(rows)
user, err := repo.GetByID(context.Background(), userID)
require.NoError(t, err)
assert.Equal(t, "Test User", user.Name)
assert.Equal(t, "test@example.com", user.Email)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUserRepository_GetByID_NotFound(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
repo := repository.NewUserRepository(db)
userID := uuid.New()
mock.ExpectQuery(`SELECT .+ FROM users WHERE id = \$1`).
WithArgs(userID).
WillReturnError(sql.ErrNoRows)
user, err := repo.GetByID(context.Background(), userID)
assert.Nil(t, user)
assert.ErrorIs(t, err, repository.ErrNotFound)
require.NoError(t, mock.ExpectationsWereMet())
}
func setupTestDB(t *testing.T) (*sql.DB, func()) {
t.Helper()
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second),
},
Started: true,
})
require.NoError(t, err)
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://test:test@%s:%s/testdb?sslmode=disable", host, port.Port())
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)
// Run migrations
runMigrations(t, db)
cleanup := func() {
db.Close()
container.Terminate(ctx)
}
return db, cleanup
}
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
db, cleanup := setupTestDB(t)
defer cleanup()
repo := repository.NewUserRepository(db)
// Test Create
user := &model.User{
ID: uuid.New(),
Email: "integration@test.com",
Name: "Integration User",
}
err := repo.Create(context.Background(), user)
require.NoError(t, err)
// Test GetByID
found, err := repo.GetByID(context.Background(), user.ID)
require.NoError(t, err)
assert.Equal(t, user.Email, found.Email)
assert.Equal(t, user.Name, found.Name)
}
internal/testutil/)// internal/testutil/helpers.go
package testutil
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
// NewRequest creates an httptest.Request with optional JSON body.
func NewRequest(t *testing.T, method, path string, body interface{}) *http.Request {
t.Helper()
var req *http.Request
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
req = httptest.NewRequest(method, path, strings.NewReader(string(b)))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
return req
}
// WithChiURLParam adds Chi URL parameters to the request context.
func WithChiURLParam(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// WithChiURLParams adds multiple Chi URL parameters.
func WithChiURLParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for key, value := range params {
rctx.URLParams.Add(key, value)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// DecodeResponse decodes the JSON response body into the given struct.
func DecodeResponse(t *testing.T, rec *httptest.ResponseRecorder, v interface{}) {
t.Helper()
err := json.NewDecoder(rec.Body).Decode(v)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
}
// AssertStatus checks the response status code.
func AssertStatus(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
t.Helper()
if rec.Code != expected {
t.Errorf("expected status %d, got %d. Body: %s", expected, rec.Code, rec.Body.String())
}
}
// internal/testutil/factories.go
package testutil
import (
"time"
"github.com/google/uuid"
"myapp/internal/model"
)
// NewUser creates a test user with sensible defaults. Override fields as needed.
func NewUser(overrides ...func(*model.User)) *model.User {
u := &model.User{
ID: uuid.New(),
Email: "user@example.com",
Name: "Test User",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for _, fn := range overrides {
fn(u)
}
return u
}
// WithEmail overrides the user's email.
func WithEmail(email string) func(*model.User) {
return func(u *model.User) { u.Email = email }
}
// WithName overrides the user's name.
func WithName(name string) func(*model.User) {
return func(u *model.User) { u.Name = name }
}
func TestAuthMiddleware(t *testing.T) {
tests := []struct {
name string
authHeader string
mockFn func(*MockAuthService)
wantStatus int
}{
{
name: "success - valid token",
authHeader: "Bearer valid-token-123",
mockFn: func(m *MockAuthService) {
m.On("ValidateToken", mock.Anything, "valid-token-123").
Return(&model.User{ID: uuid.New()}, nil)
},
wantStatus: http.StatusOK,
},
{
name: "error - missing auth header",
authHeader: "",
mockFn: func(m *MockAuthService) {},
wantStatus: http.StatusUnauthorized,
},
{
name: "error - invalid token format",
authHeader: "InvalidFormat",
mockFn: func(m *MockAuthService) {},
wantStatus: http.StatusUnauthorized,
},
{
name: "error - expired token",
authHeader: "Bearer expired-token",
mockFn: func(m *MockAuthService) {
m.On("ValidateToken", mock.Anything, "expired-token").
Return(nil, service.ErrTokenExpired)
},
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authSvc := new(MockAuthService)
tt.mockFn(authSvc)
// Create a test handler behind the middleware
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mw := middleware.Auth(authSvc)
handler := mw(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, tt.wantStatus, rec.Code)
authSvc.AssertExpectations(t)
})
}
}
func TestServiceError_Wrapping(t *testing.T) {
// Test that service errors wrap correctly for handler consumption
repo := new(MockUserRepository)
repo.On("GetByID", mock.Anything, mock.Anything).
Return(nil, fmt.Errorf("connection refused"))
svc := service.NewUserService(repo)
_, err := svc.GetByID(context.Background(), uuid.New())
require.Error(t, err)
// Verify the error wraps with context
assert.Contains(t, err.Error(), "get user")
assert.Contains(t, err.Error(), "connection refused")
}
func TestAPIError_Response(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
wantMsg string
}{
{"not found", service.ErrNotFound, 404, "not found"},
{"conflict", service.ErrConflict, 409, "already exists"},
{"validation", service.ErrValidation, 400, "invalid input"},
{"unauthorized", service.ErrUnauthorized, 401, "unauthorized"},
{"internal", fmt.Errorf("unexpected"), 500, "internal server error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, msg := handler.MapErrorToResponse(tt.err)
assert.Equal(t, tt.wantStatus, status)
assert.Contains(t, msg, tt.wantMsg)
})
}
}
func TestService_RespectsContextCancellation(t *testing.T) {
repo := new(MockUserRepository)
svc := service.NewUserService(repo)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err := svc.GetByID(ctx, uuid.New())
assert.ErrorIs(t, err, context.Canceled)
}
func TestService_RespectsContextTimeout(t *testing.T) {
repo := new(MockUserRepository)
repo.On("List", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
time.Sleep(100 * time.Millisecond) // Simulate slow query
}).
Return(nil, 0, nil)
svc := service.NewUserService(repo)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, _, err := svc.List(ctx, model.ListOptions{})
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
internal/
├── handler/
│ ├── user_handler.go
│ ├── user_handler_test.go ← handler tests (httptest + mock service)
│ ├── project_handler.go
│ └── project_handler_test.go
├── service/
│ ├── user_service.go
│ ├── user_service_test.go ← service tests (table-driven + mock repo)
│ ├── project_service.go
│ └── project_service_test.go
├── repository/
│ ├── user_repository.go
│ ├── user_repository_test.go ← repo tests (sqlmock)
│ ├── project_repository.go
│ └── project_repository_test.go
├── middleware/
│ ├── auth.go
│ └── auth_test.go ← middleware tests
├── model/
│ └── user.go ← no tests needed (pure structs)
└── testutil/
├── helpers.go ← shared test utilities
└── factories.go ← test data factories
| Convention | Example |
|---|---|
| Test file | user_service_test.go (same dir as source) |
| Test function | TestUserService_Create (Type_Method) |
| Subtest name | "success - creates user with valid input" (outcome-based) |
| Mock type | MockUserRepository (Mock + interface name) |
| Test helper | testutil.NewUser() (in testutil package) |
| Helper function | Always call t.Helper() first line |
| Package | Target | Rationale |
|---|---|---|
internal/service/ | 80%+ | Business logic, highest value |
internal/handler/ | 70%+ | HTTP layer, test all status codes |
internal/repository/ | 60%+ | Data layer, sqlmock or integration |
internal/middleware/ | 80%+ | Cross-cutting, every branch matters |
internal/model/ | N/A | Pure structs, no logic to test |
cmd/ | N/A | Wiring code, tested via integration |
Always run tests with the -race flag:
go test -v -race -cover ./...
func TestConcurrentOperations(t *testing.T) {
tests := []struct { ... }
for _, tt := range tests {
tt := tt // Capture range variable for parallel tests
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Each test gets its own mock instance
repo := new(MockUserRepository)
tt.mockFn(repo)
// ... test logic
})
}
}
t.Parallel() where safe — tests without shared state should run in parallel-race flag always — detect data races earlyrequire for fatal setup assertions — fail fast if setup is brokenassert for test assertions — continue to collect multiple failuresAssertExpectations — verify mocks received expected callst.Helper() in all helper functions — correct line numbers in test outputdefer for DB connections, temp files, test containerstesting.Short() to skip slow tests in quick runsreferences/. Changes to SKILL.md or scripts/ require user approval.