chore: initial plan
This commit is contained in:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user