chore: initial plan

This commit is contained in:
claude
2026-07-24 16:23:24 +07:00
commit d47a07af46
32 changed files with 5724 additions and 0 deletions
@@ -0,0 +1,42 @@
# Test Helpers
## Test Timeout
For tests that may hang, use a timeout helper that panics with caller location:
```go
// https://github.com/stretchr/testify/issues/1101
func testWithTimeout(t *testing.T, timeout time.Duration) {
t.Helper()
testFinished := make(chan struct{})
t.Cleanup(func() {
close(testFinished)
})
var pc [1]uintptr
n := runtime.Callers(2, pc[:])
line, funcName := "", ""
if n > 0 {
frames := runtime.CallersFrames(pc[:])
frame, _ := frames.Next()
line = frame.File + ":" + strconv.Itoa(frame.Line)
funcName = frame.Function
}
go func() {
select {
case <-testFinished:
case <-time.After(timeout):
panic(fmt.Sprintf("%s: Test timed out after: %v\n%s", funcName, timeout, line))
}
}()
}
// Usage
func TestLongRunningOperation(t *testing.T) {
testWithTimeout(t, 2*time.Second)
result := LongRunningOperation()
// If this takes longer than 2 seconds, the test panics with location info
}
```
@@ -0,0 +1,84 @@
# HTTP Handler Testing
Use `httptest` package for testing HTTP handlers without starting a server.
## Basic Handler Test
```go
func TestCreateUserHandler(t *testing.T) {
tests := []struct {
name string
body string
expectedStatus int
}{
{
name: "valid request",
body: `{"name": "Alice", "email": "alice@example.com"}`,
expectedStatus: http.StatusCreated,
},
{
name: "invalid JSON",
body: `invalid json`,
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler := http.HandlerFunc(CreateUserHandler)
handler.ServeHTTP(w, req)
is.Equal(tt.expectedStatus, w.Code)
})
}
}
```
## Query Parameters and Headers
```go
func TestListUsersHandler(t *testing.T) {
tests := []struct {
name string
query string
authHeader string
expectedStatus int
}{
{
name: "paginated results",
query: "?page=1&limit=10",
authHeader: "Bearer token123",
expectedStatus: http.StatusOK,
},
{
name: "missing auth",
query: "?page=1",
authHeader: "",
expectedStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/users"+tt.query, nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
w := httptest.NewRecorder()
handler := AuthMiddleware(ListUsersHandler)
handler.ServeHTTP(w, req)
is.Equal(tt.expectedStatus, w.Code)
})
}
}
```
@@ -0,0 +1,187 @@
# Integration Testing
## Docker Compose Fixture
Create `pkg/myfeature/testdata/docker-compose.yml` for test services:
```yaml
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
```
## SQL Schema Fixture
Create `pkg/myfeature/testdata/schema.sql` for database initialization:
```sql
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## Test Data Fixture
Create `pkg/myfeature/testdata/testdata.sql`:
```sql
INSERT INTO users (name, email) VALUES
('Alice Johnson', 'alice@example.com'),
('Bob Smith', 'bob@example.com'),
('Charlie Brown', 'charlie@example.com');
INSERT INTO orders (user_id, amount, status) VALUES
(1, 100.00, 'completed'),
(1, 50.00, 'pending'),
(2, 200.00, 'completed');
```
## Using Fixtures in Tests
```go
//go:build integration
package database_test
import (
"database/sql"
"os"
"os/exec"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type DatabaseTestSuite struct {
suite.Suite
db *sql.DB
}
func (s *DatabaseTestSuite) SetupSuite() {
cmd := exec.Command("docker-compose", "-f", "testdata/docker-compose.yml", "up", "-d")
if err := cmd.Run(); err != nil {
s.T().Fatalf("failed to start docker-compose: %v", err)
}
time.Sleep(5 * time.Second)
db, err := sql.Open("postgres", "postgres://test:test@localhost:5433/testdb?sslmode=disable")
if err != nil {
s.T().Fatalf("failed to connect to database: %v", err)
}
s.db = db
schema, _ := os.ReadFile("testdata/schema.sql")
_, err = db.Exec(string(schema))
if err != nil {
s.T().Fatalf("failed to run schema: %v", err)
}
}
func (s *DatabaseTestSuite) TearDownSuite() {
cmd := exec.Command("docker-compose", "-f", "testdata/docker-compose.yml", "down", "-v")
_ = cmd.Run()
}
func (s *DatabaseTestSuite) SetupTest() {
_, err := s.db.Exec("TRUNCATE TABLE orders, users CASCADE")
if err != nil {
s.T().Fatalf("failed to clear database: %v", err)
}
testdata, _ := os.ReadFile("testdata/testdata.sql")
_, err = s.db.Exec(string(testdata))
if err != nil {
s.T().Fatalf("failed to load test data: %v", err)
}
}
func (s *DatabaseTestSuite) TestUserCount() {
is := assert.New(s.T())
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
is.NoError(err)
is.Equal(3, count)
}
func (s *DatabaseTestSuite) TestOrderSum() {
is := assert.New(s.T())
var sum float64
err := s.db.QueryRow("SELECT SUM(amount) FROM orders").Scan(&sum)
is.NoError(err)
is.InDelta(350.0, sum, 0.01)
}
func TestDatabaseTestSuite(t *testing.T) {
suite.Run(t, new(DatabaseTestSuite))
}
```
## Test Helper with Embedded Fixtures
```go
package myfeature
import (
"database/sql"
"embed"
)
//go:embed testdata/schema.sql testdata/testdata.sql
var fixtures embed.FS
func SetupDB(db *sql.DB) error {
schema, err := fixtures.ReadFile("testdata/schema.sql")
if err != nil {
return err
}
if _, err := db.Exec(string(schema)); err != nil {
return err
}
data, err := fixtures.ReadFile("testdata/testdata.sql")
if err != nil {
return err
}
if _, err := db.Exec(string(data)); err != nil {
return err
}
return nil
}
```
@@ -0,0 +1,206 @@
# Mocking and Test Fixtures
## Mocks with testify/mock
Create interfaces for your dependencies, then mock them.
> For the full testify/mock API (argument matchers, call modifiers, verification), see the `samber/cc-skills-golang@golang-stretchr-testify` skill.
```go
// Define the interface
type Database interface {
GetUser(id string) (*User, error)
CreateUser(user *User) error
}
// Mock implementation
type MockDatabase struct {
mock.Mock
}
func (m *MockDatabase) GetUser(id string) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockDatabase) CreateUser(user *User) error {
args := m.Called(user)
return args.Error(0)
}
// Usage in tests
func TestService_GetUser(t *testing.T) {
is := assert.New(t)
mockDB := new(MockDatabase)
service := NewService(mockDB)
expectedUser := &User{ID: "1", Name: "John"}
mockDB.On("GetUser", "1").Return(expectedUser, nil)
user, err := service.GetUser("1")
is.NoError(err)
is.Equal(expectedUser, user)
mockDB.AssertExpectations(t)
}
func TestService_GetUser_NotFound(t *testing.T) {
is := assert.New(t)
mockDB := new(MockDatabase)
service := NewService(mockDB)
mockDB.On("GetUser", "999").Return(nil, ErrNotFound)
user, err := service.GetUser("999")
is.Error(err)
is.ErrorIs(err, ErrNotFound)
is.Nil(user)
mockDB.AssertExpectations(t)
}
```
## Mock Organization
For larger codebases, organize mocks alongside the code they mock:
```go
// user_service.go
type UserService struct {
db Database
email EmailService
}
type Database interface {
GetUser(id string) (*User, error)
CreateUser(user *User) error
}
type EmailService interface {
SendWelcomeEmail(to string) error
}
```
```go
// user_service_test.go
package mypackage_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"path/to/mypackage"
)
// MockDatabase implements mypackage.Database
type MockDatabase struct {
mock.Mock
}
func (m *MockDatabase) GetUser(id string) (*mypackage.User, error) {
args := m.Called(id)
if args.Get(0) == nil { return nil, args.Error(1) }
return args.Get(0).(*mypackage.User), args.Error(1)
}
func (m *MockDatabase) CreateUser(user *mypackage.User) error {
return m.Called(user).Error(0)
}
// MockEmailService implements mypackage.EmailService
type MockEmailService struct {
mock.Mock
}
func (m *MockEmailService) SendWelcomeEmail(to string) error {
return m.Called(to).Error(0)
}
func TestUserService_CreateUser(t *testing.T) {
mockDB := new(MockDatabase)
mockEmail := new(MockEmailService)
service := mypackage.NewUserService(mockDB, mockEmail)
user := &mypackage.User{Name: "Test", Email: "test@example.com"}
mockDB.On("CreateUser", user).Return(nil)
mockEmail.On("SendWelcomeEmail", "test@example.com").Return(nil)
err := service.CreateUser(user)
assert.NoError(t, err)
mockDB.AssertExpectations(t)
mockEmail.AssertExpectations(t)
}
```
## Test Fixtures
Create reusable test data in a separate package or file:
```go
package fixtures
import "time"
var (
DefaultUser = &User{
ID: "user-123",
Name: "Jane Doe",
Email: "jane@example.com",
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
AdminUser = &User{
ID: "admin-1",
Name: "Admin User",
Email: "admin@example.com",
Role: "admin",
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
)
func NewUser(name, email string) *User {
return &User{
ID: "user-" + uuid.New().String(),
Name: name,
Email: email,
CreatedAt: time.Now(),
}
}
```
## Time Mocking
Use `clockwork` to test time-dependent code without `time.Sleep()`:
```go
import (
"testing"
"time"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
)
func TestScheduler_AddJob(t *testing.T) {
is := assert.New(t)
fakeClock := clockwork.NewFakeClock()
scheduler := NewScheduler(fakeClock)
job := &Job{ID: "1", RunAt: time.Now().Add(1 * time.Hour)}
scheduler.AddJob(job)
is.Equal(1, scheduler.PendingCount())
// Advance fake time
fakeClock.Advance(2 * time.Hour)
is.Equal(0, scheduler.PendingCount())
}
```
Install clockwork:
```bash
go get github.com/jonboulle/clockwork
```