This guide covers strategies for testing HxComponents applications at multiple levels.
HxComponents provides built-in test helpers to simulate the component lifecycle without needing HTTP requests. These helpers are especially useful for unit testing components in isolation.
The SimulateEvent helper simulates a complete event lifecycle, calling Init, BeforeEvent, the event handler, AfterEvent, and Process in the correct order. This is perfect for testing event handlers without setting up HTTP requests.
package counter_test
import (
"context"
"testing"
"github.com/ocomsoft/HxComponents/components"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCounterIncrement(t *testing.T) {
// Create component
counter := &counter.CounterComponent{Count: 5}
ctx := context.Background()
// Simulate increment event
err := components.SimulateEvent(ctx, counter, "increment")
require.NoError(t, err)
// Assert the count was incremented
assert.Equal(t, 6, counter.Count)
}
func TestMultipleEvents(t *testing.T) {
counter := &counter.CounterComponent{Count: 0}
ctx := context.Background()
// Simulate multiple clicks
for i := 0; i < 5; i++ {
err := components.SimulateEvent(ctx, counter, "increment")
require.NoError(t, err)
}
assert.Equal(t, 5, counter.Count)
}Lifecycle executed by SimulateEvent:
Init(ctx)- if component implementsInitializerBeforeEvent(ctx, eventName)- if component implementsBeforeEventHandlerOn{EventName}(ctx)- the event handler methodAfterEvent(ctx, eventName)- if component implementsAfterEventHandlerProcess(ctx)- if component implementsProcessor
The SimulateProcess helper simulates a non-event request (e.g., a simple GET or POST without an event). It calls Init and Process only.
func TestFormProcessing(t *testing.T) {
form := &login.LoginComponent{
Username: "testuser",
Password: "password123",
}
ctx := context.Background()
err := components.SimulateProcess(ctx, form)
require.NoError(t, err)
// Assert redirect was set
assert.Equal(t, "/dashboard", form.RedirectTo)
}Lifecycle executed by SimulateProcess:
Init(ctx)- if component implementsInitializerProcess(ctx)- if component implementsProcessor
The test helpers make it easy to verify that lifecycle hooks are called in the correct order:
type TestComponent struct {
Value int
Log []string
}
func (t *TestComponent) Init(ctx context.Context) error {
t.Log = append(t.Log, "Init")
if t.Value == 0 {
t.Value = 10
}
return nil
}
func (t *TestComponent) BeforeEvent(ctx context.Context, eventName string) error {
t.Log = append(t.Log, fmt.Sprintf("BeforeEvent:%s", eventName))
return nil
}
func (t *TestComponent) OnProcess(ctx context.Context) error {
t.Log = append(t.Log, "OnProcess")
t.Value++
return nil
}
func (t *TestComponent) AfterEvent(ctx context.Context, eventName string) error {
t.Log = append(t.Log, fmt.Sprintf("AfterEvent:%s", eventName))
return nil
}
func (t *TestComponent) Process(ctx context.Context) error {
t.Log = append(t.Log, "Process")
return nil
}
func (t *TestComponent) Render(ctx context.Context, w io.Writer) error {
fmt.Fprintf(w, "<div>%d</div>", t.Value)
return nil
}
func TestLifecycleOrder(t *testing.T) {
component := &TestComponent{Value: 0}
ctx := context.Background()
err := components.SimulateEvent(ctx, component, "process")
require.NoError(t, err)
// Verify lifecycle was executed in correct order
expected := []string{
"Init",
"BeforeEvent:process",
"OnProcess",
"AfterEvent:process",
"Process",
}
assert.Equal(t, expected, component.Log)
// Verify Init set default value, then OnProcess incremented it
assert.Equal(t, 11, component.Value)
}The test helpers properly handle errors at each lifecycle stage:
func TestErrorInBeforeEvent(t *testing.T) {
component := &MyComponent{
FailPhase: "before",
}
ctx := context.Background()
err := components.SimulateEvent(ctx, component, "submit")
require.Error(t, err)
assert.Contains(t, err.Error(), "BeforeEvent failed")
}
func TestErrorInEventHandler(t *testing.T) {
component := &MyComponent{
FailPhase: "event",
}
ctx := context.Background()
err := components.SimulateEvent(ctx, component, "submit")
require.Error(t, err)
assert.Contains(t, err.Error(), "event handler failed")
// Process should not have been called
assert.False(t, component.ProcessCalled)
}Use SimulateEvent/SimulateProcess when:
- Testing component logic in isolation
- Verifying lifecycle hook order
- Running fast unit tests
- Testing error conditions
- You don't need to test HTTP-specific behavior
Use HTTP tests (httptest) when:
- Testing form parsing and decoding
- Verifying HTTP headers (HTMX headers, redirects)
- Testing the full HTTP request/response cycle
- Integration testing with the registry
// Fast unit test - uses SimulateEvent
func TestCounterLogic(t *testing.T) {
counter := &counter.CounterComponent{Count: 5}
err := components.SimulateEvent(context.Background(), counter, "increment")
require.NoError(t, err)
assert.Equal(t, 6, counter.Count)
}
// Integration test - uses httptest
func TestCounterHTTP(t *testing.T) {
registry := components.NewRegistry()
components.Register[*counter.CounterComponent](registry, "counter")
form := url.Values{}
form.Add("count", "5")
form.Add("hxc-event", "increment")
req := httptest.NewRequest(http.MethodPost, "/component/counter",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
registry.Handler(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "6")
}Test component logic in isolation:
package counter_test
import (
"testing"
"myproject/components/counter"
"github.com/stretchr/testify/assert"
)
func TestCounterIncrement(t *testing.T) {
// Create component
c := &counter.CounterComponent{Count: 0}
// Call event handler
err := c.OnIncrement()
// Assert
assert.NoError(t, err)
assert.Equal(t, 1, c.Count)
}
func TestCounterDecrement(t *testing.T) {
c := &counter.CounterComponent{Count: 5}
err := c.OnDecrement()
assert.NoError(t, err)
assert.Equal(t, 4, c.Count)
}
func TestCounterDoubled(t *testing.T) {
c := &counter.CounterComponent{Count: 7}
assert.Equal(t, 14, c.Doubled())
}Test HTTP handlers and component registration:
package counter_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/ocomsoft/HxComponents/components"
"myproject/components/counter"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCounterHTTPHandler(t *testing.T) {
// Setup registry
registry := components.NewRegistry()
components.Register[*counter.CounterComponent](registry, "counter")
// Test POST request
form := url.Values{}
form.Add("count", "5")
form.Add("hxc-event", "increment")
req := httptest.NewRequest(http.MethodPost, "/component/counter",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
registry.Handler(w, req)
// Assert response
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "6") // Count should be 6
}
func TestCounterGETRequest(t *testing.T) {
registry := components.NewRegistry()
components.Register[*counter.CounterComponent](registry, "counter")
// Test GET request with query parameters
req := httptest.NewRequest(http.MethodGet,
"/component/counter?count=10", nil)
w := httptest.NewRecorder()
registry.Handler(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "10")
}Test complete user workflows in a real browser:
package counter_test
import (
"testing"
"myproject/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCounterE2E(t *testing.T) {
// Start test server
server := testutil.NewTestServer(t)
defer server.Close()
// Start Playwright
pt := testutil.NewPlaywrightTest(t)
defer pt.Close()
t.Run("counter increments on button click", func(t *testing.T) {
// Navigate to page
pt.Goto(server.URL)
// Find counter
counter := pt.Page.Locator(".counter")
span := counter.Locator("span")
// Verify initial value
text, err := span.TextContent()
require.NoError(t, err)
assert.Equal(t, "0", text)
// Click increment button
incrementBtn := counter.Locator("button:has-text('+')")
err = incrementBtn.Click()
require.NoError(t, err)
// Wait for HTMX to update
pt.WaitForHTMX()
// Verify updated value
text, err = span.TextContent()
require.NoError(t, err)
assert.Equal(t, "1", text)
})
t.Run("counter handles rapid clicks", func(t *testing.T) {
pt.Goto(server.URL)
counter := pt.Page.Locator(".counter")
incrementBtn := counter.Locator("button:has-text('+')")
// Click multiple times rapidly
for i := 0; i < 5; i++ {
incrementBtn.Click()
pt.WaitForHTMX()
}
span := counter.Locator("span")
text, _ := span.TextContent()
assert.Equal(t, "5", text)
})
}func TestComponentLifecycle(t *testing.T) {
c := &todolist.TodoListComponent{
Items: []todolist.TodoItem{
{ID: 1, Text: "Test", Completed: false},
},
}
// Test BeforeEvent
ctx := context.Background()
err := c.BeforeEvent(ctx, "addItem")
assert.NoError(t, err)
// Test event handler
c.NewItemText = "New item"
err = c.OnAddItem()
assert.NoError(t, err)
assert.Len(t, c.Items, 2)
assert.Equal(t, "", c.NewItemText) // Should be cleared
// Test AfterEvent
err = c.AfterEvent(ctx, "addItem")
assert.NoError(t, err)
assert.Equal(t, "addItem", c.LastEvent)
assert.Equal(t, 1, c.EventCount)
}func TestValidationErrors(t *testing.T) {
form := &userform.UserFormComponent{
Email: "",
Password: "short",
}
// Trigger validation
ctx := context.Background()
err := form.BeforeEvent(ctx, "submit")
assert.NoError(t, err) // BeforeEvent shouldn't error
// Check validation errors were set
assert.NotEmpty(t, form.EmailError)
assert.NotEmpty(t, form.PasswordError)
assert.False(t, form.IsValid())
// OnSubmit should fail
err = form.OnSubmit()
assert.Error(t, err)
// Fix validation
form.Email = "test@example.com"
form.Password = "password123"
err = form.BeforeEvent(ctx, "submit")
assert.NoError(t, err)
assert.Empty(t, form.EmailError)
assert.Empty(t, form.PasswordError)
assert.True(t, form.IsValid())
}func TestErrorHandling(t *testing.T) {
c := &todolist.TodoListComponent{}
// Test adding empty item
c.NewItemText = ""
err := c.OnAddItem()
assert.Error(t, err)
assert.Contains(t, err.Error(), "empty")
// Test deleting non-existent item
c.ItemID = 999
err = c.OnDeleteItem()
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}func TestHTMXHeaders(t *testing.T) {
registry := components.NewRegistry()
components.Register[*login.LoginComponent](registry, "login")
form := url.Values{}
form.Add("username", "demo")
form.Add("password", "password")
form.Add("hxc-event", "submit")
req := httptest.NewRequest(http.MethodPost, "/component/login",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
registry.Handler(w, req)
// Check response headers
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "/dashboard", w.Header().Get("HX-Redirect"))
}func TestResponseHeaders(t *testing.T) {
c := &mycomponent.MyComponent{}
// Trigger event that sets response headers
err := c.OnSubmit()
assert.NoError(t, err)
// Check response headers were set
headers := c.GetHTMXResponseHeaders()
assert.Equal(t, "/success", headers["HX-Redirect"])
assert.Equal(t, "itemUpdated", headers["HX-Trigger"])
}func TestComponentRendering(t *testing.T) {
c := &counter.CounterComponent{Count: 42}
// Render to buffer
var buf bytes.Buffer
err := c.Render(context.Background(), &buf)
assert.NoError(t, err)
html := buf.String()
// Check rendered output
assert.Contains(t, html, "42")
assert.Contains(t, html, "hx-post=\"/component/counter\"")
assert.Contains(t, html, "hxc-event")
}func TestCounter TableDriven(t *testing.T) {
tests := []struct {
name string
initial int
operation string
expected int
shouldError bool
}{
{"increment from zero", 0, "increment", 1, false},
{"decrement from zero", 0, "decrement", -1, false},
{"increment from positive", 5, "increment", 6, false},
{"decrement from positive", 5, "decrement", 4, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &counter.CounterComponent{Count: tt.initial}
var err error
if tt.operation == "increment" {
err = c.OnIncrement()
} else {
err = c.OnDecrement()
}
if tt.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, c.Count)
}
})
}
}Create helper functions for common test scenarios:
package testutil
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/ocomsoft/HxComponents/components"
"github.com/playwright-community/playwright-go"
)
type TestServer struct {
*httptest.Server
Registry *components.Registry
}
func NewTestServer(t *testing.T) *TestServer {
registry := components.NewRegistry()
// Register all components
// components.Register[*counter.CounterComponent](registry, "counter")
// ...
router := chi.NewRouter()
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)
// Add page routes
// ...
server := httptest.NewServer(router)
t.Cleanup(server.Close)
return &TestServer{
Server: server,
Registry: registry,
}
}
type PlaywrightTest struct {
pw *playwright.Playwright
browser playwright.Browser
Page playwright.Page
}
func NewPlaywrightTest(t *testing.T) *PlaywrightTest {
pw, err := playwright.Run()
if err != nil {
t.Fatal(err)
}
browser, err := pw.Chromium.Launch()
if err != nil {
t.Fatal(err)
}
page, err := browser.NewPage()
if err != nil {
t.Fatal(err)
}
pt := &PlaywrightTest{
pw: pw,
browser: browser,
Page: page,
}
t.Cleanup(func() {
page.Close()
browser.Close()
pw.Stop()
})
return pt
}
func (pt *PlaywrightTest) Goto(url string) {
if _, err := pt.Page.Goto(url); err != nil {
panic(err)
}
}
func (pt *PlaywrightTest) WaitForHTMX() {
// Wait for htmx requests to complete
pt.Page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{
State: playwright.LoadStateNetworkidle,
})
time.Sleep(100 * time.Millisecond) // Small buffer for HTMX swap
}
func (pt *PlaywrightTest) Close() {
pt.Page.Close()
pt.browser.Close()
pt.pw.Stop()
}-
Test at Multiple Levels
- Unit tests for component logic
- Integration tests for HTTP handlers
- E2E tests for critical user workflows
-
Use Table-Driven Tests
- Test multiple scenarios efficiently
- Easy to add new test cases
-
Test Error Paths
- Test validation failures
- Test invalid inputs
- Test error recovery
-
Mock External Dependencies
- Mock database calls
- Mock external APIs
- Use test doubles for services
-
Test Lifecycle Hooks
- Ensure BeforeEvent/AfterEvent work correctly
- Test state persistence
- Test side effects
-
Test HTMX Behavior
- Verify HTMX headers
- Test response headers
- Test swap behavior
-
Use Test Fixtures
- Create reusable test data
- Use factories for complex objects
-
Keep Tests Fast
- Use unit tests for business logic
- Reserve E2E tests for critical paths
- Run tests in parallel when possible
-
Test Accessibility
- Use Playwright's accessibility testing
- Check ARIA attributes
- Test keyboard navigation
-
Continuous Integration
- Run tests on every commit
- Use GitHub Actions or similar
- Generate coverage reports