readysite / docs / testing.md
8.0 KB
testing.md

Testing

Patterns for testing ReadySite applications.

Running Tests

go test ./...                                        # All tests
go test -v ./pkg/database/...                        # Single package
go test -run TestCollection ./...                    # Single test by name
go test -v -run TestCollection/Insert ./pkg/database # Subtest

Database Testing

Use in-memory databases for fast, isolated tests:

import (
    "github.com/readysite/readysite/pkg/database"
    "github.com/readysite/readysite/pkg/database/engines"
)

type TestEntity struct {
    database.Model
    Name   string
    Status string
}

func TestInsert(t *testing.T) {
    db := engines.NewMemory()
    entities := database.Manage(db, new(TestEntity))

    entity := &TestEntity{Name: "test", Status: "active"}
    if _, err := entities.Insert(entity); err != nil {
        t.Fatalf("insert failed: %v", err)
    }

    if entity.ID == "" {
        t.Error("expected ID to be generated")
    }

    found, err := entities.Get(entity.ID)
    if err != nil {
        t.Fatalf("get failed: %v", err)
    }
    if found.Name != "test" {
        t.Errorf("expected 'test', got %q", found.Name)
    }
}

Indexes in Tests

Add indexes just like production code:

db := engines.NewMemory()
users := database.Manage(db, new(User),
    database.WithUniqueIndex[User]("Email"),
    database.WithIndex[User]("Role"),
)

Controller Testing

Test Setup Pattern

Use sync.Once for one-time test database initialization:

var (
    setupOnce sync.Once
    testApp   *application.App
    testAdmin *models.User
)

func setupTestDB() {
    setupOnce.Do(func() {
        // Replace with in-memory database
        models.DB = engines.NewMemory()

        // Reinitialize all collections
        models.Users = database.Manage(models.DB, new(models.User),
            database.WithUniqueIndex[models.User]("Email"),
        )
        models.Posts = database.Manage(models.DB, new(models.Post))

        // Create test user
        testAdmin = &models.User{
            Email: "admin@test.com",
            Name:  "Test Admin",
            Role:  "admin",
        }
        testAdmin.ID = "test-admin"
        models.Users.Insert(testAdmin)

        // Create test app
        testApp = application.New(
            application.WithViews(testViews),
            application.WithController(Blog()),
        )
    })
}

func init() {
    setupTestDB()
}

HTTP Request Testing

func TestCreate(t *testing.T) {
    formData := url.Values{
        "title":   {"My Post"},
        "slug":    {"my-post"},
        "content": {"Hello world"},
    }

    req := httptest.NewRequest("POST", "/blog",
        strings.NewReader(formData.Encode()))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    authenticateRequest(req)
    w := httptest.NewRecorder()

    c := &BlogController{}
    c.BaseController.Setup(testApp)
    handler := c.Handle(req)
    handler.(*BlogController).Create(w, req)

    if w.Code != http.StatusSeeOther {
        t.Errorf("expected 303, got %d", w.Code)
    }
}

Path Values

Set path values for routes with parameters:

req := httptest.NewRequest("GET", "/blog/my-post", nil)
req.SetPathValue("slug", "my-post")

Authenticated Requests

Create a JWT cookie for authenticated tests:

func authenticateRequest(req *http.Request) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "user_id": testAdmin.ID,
        "exp":     time.Now().Add(24 * time.Hour).Unix(),
    })
    tokenString, _ := token.SignedString([]byte("dev-only-not-for-production"))
    req.AddCookie(&http.Cookie{
        Name:  "session",
        Value: tokenString,
    })
}

HTMX Request Testing

// Test HTMX redirect behavior
req := httptest.NewRequest("POST", "/blog", body)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()

// HTMX redirects return 200 + HX-Location header
if w.Code != http.StatusOK {
    t.Errorf("HTMX redirect should be 200, got %d", w.Code)
}
if loc := w.Header().Get("HX-Location"); loc != "/blog/my-post" {
    t.Errorf("expected HX-Location, got %q", loc)
}

File Upload Testing

func TestUpload(t *testing.T) {
    body := &bytes.Buffer{}
    writer := multipart.NewWriter(body)

    part, _ := writer.CreateFormFile("file", "test.txt")
    part.Write([]byte("Hello, World!"))
    writer.Close()

    req := httptest.NewRequest("POST", "/files", body)
    req.Header.Set("Content-Type", writer.FormDataContentType())
    w := httptest.NewRecorder()

    // ... invoke controller method
}

Mock Assistant

Use the mock provider for AI tests without API calls:

Basic Response

import "github.com/readysite/readysite/pkg/assistant/providers/mock"

ai := mock.New(mock.Config{
    Response: "Hello, World!",
})

resp, err := ai.Chat(ctx, assistant.ChatRequest{
    Messages: []assistant.Message{
        assistant.NewUserMessage("Hi"),
    },
})
// resp.Content == "Hello, World!"
// resp.FinishReason == "stop"

With Tool Calls

ai := mock.New(mock.Config{
    Response: "Let me create that page.",
    ToolCalls: []assistant.ToolCall{
        {ID: "call_1", Name: "create_page", Arguments: `{"title":"Test"}`},
    },
})

resp, _ := ai.Chat(ctx, assistant.ChatRequest{
    Messages: []assistant.Message{
        assistant.NewUserMessage("Create a page"),
    },
    Tools: []assistant.Tool{
        assistant.NewTool("create_page", "Create a page").
            String("title", "Page title", true).
            Build(),
    },
})
// resp.FinishReason == "tool_calls"
// len(resp.ToolCalls) == 1

Streaming

ai := mock.New(mock.Config{
    Response:    "Hello World",
    StreamDelay: 10 * time.Millisecond,
})

stream, _ := ai.Stream(ctx, req)
defer stream.Close()

resp, _ := stream.Collect()
// resp.Content == "Hello World"

Error Simulation

ai := mock.New(mock.Config{
    Error: fmt.Errorf("API unavailable"),
})

_, err := ai.Chat(ctx, req)
// err != nil

Platform Mocking

Use the mock platform provider for infrastructure tests:

import "github.com/readysite/readysite/pkg/platform/providers/mock"

p := mock.New()

server, _ := p.CreateServer(platform.ServerOptions{
    Name:   "test-server",
    Size:   platform.Small,
    Region: platform.NYC,
})
// server.Status == "active"

volume, _ := p.CreateVolume("test-vol", 50, platform.NYC)
p.AttachVolume(volume.ID, server.ID)

Table-Driven Tests

Follow Go conventions with table-driven tests:

func TestSlugValidation(t *testing.T) {
    tests := []struct {
        name    string
        slug    string
        wantErr bool
    }{
        {"valid", "my-page", false},
        {"valid single char", "a", false},
        {"empty", "", true},
        {"uppercase", "My-Page", true},
        {"starts with hyphen", "-page", true},
        {"reserved", "admin", true},
        {"too long", strings.Repeat("a", 51), true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := content.ValidateSlug(tt.slug)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", tt.slug, err, tt.wantErr)
            }
        })
    }
}

Test Data

Use embedded test views for template tests:

//go:embed testdata/views
var testViewsRaw embed.FS
var testViews, _ = fs.Sub(testViewsRaw, "testdata")

Create minimal test templates in testdata/views/:

testdata/views/
├── layouts/
│   └── base.html
├── partials/
│   └── card.html
└── pages/
    └── test.html

Test File Organization

myapp/
├── controllers/
│   ├── blog.go
│   ├── blog_test.go           # Tests for blog controller
│   └── testhelper_test.go     # Shared test setup
├── models/
│   ├── post.go
│   └── post_test.go           # Model tests
└── internal/
    ├── validator.go
    └── validator_test.go      # Internal package tests
← Back