HTMX + Go (Gin/Chi) Testing: Rendering Partials and htmx-boost

HTMX + Go (Gin/Chi) Testing: Rendering Partials and htmx-boost

Go's net/http/httptest package makes testing HTMX endpoints straightforward — create a recorder, set the HX-Request header, call your handler, and assert on the HTML response. This works with any Go web framework: Gin, Chi, Echo, or stdlib http.ServeMux. Go's speed makes HTMX integration tests fast and parallelizable.

Key Takeaways

Use httptest.NewRecorder() + http.NewRequest(). Go's standard library provides everything you need to test HTTP handlers without starting a real server.

Set HX-Request: true header on the request. This is what HTMX sends — check r.Header.Get("HX-Request") == "true" in your handlers.

Test both template branches. HTMX handlers typically render a partial template or a full layout. Test both paths in separate test cases.

html/template output is deterministic. Unlike JavaScript rendering, Go templates produce the same HTML for the same data. String matching and strings.Contains work reliably.

Table-driven tests scale well for HTMX endpoints. Each row can be a different input + HX-Request header combination with expected HTML output.

Go HTMX Handler Pattern

// handlers/tasks.go
package handlers

import (
    "html/template"
    "net/http"
)

type Task struct {
    ID        int
    Title     string
    Completed bool
}

var tmpl = template.Must(template.ParseGlob("templates/**/*.html"))

func TaskList(w http.ResponseWriter, r *http.Request) {
    tasks := getTasksFromDB()

    if r.Header.Get("HX-Request") == "true" {
        // Return partial for HTMX requests
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        tmpl.ExecuteTemplate(w, "task-list-partial.html", tasks)
        return
    }

    // Full page for direct navigation
    tmpl.ExecuteTemplate(w, "task-list.html", map[string]any{
        "Tasks": tasks,
    })
}

func CreateTask(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    title := r.FormValue("title")
    if title == "" {
        w.WriteHeader(http.StatusUnprocessableEntity)
        tmpl.ExecuteTemplate(w, "task-form-partial.html", map[string]any{
            "Error": "Title is required",
        })
        return
    }

    task := saveTask(title)
    w.WriteHeader(http.StatusOK)
    tmpl.ExecuteTemplate(w, "task-item-partial.html", task)
}

func DeleteTask(w http.ResponseWriter, r *http.Request) {
    // Extract ID from URL (using chi: chi.URLParam(r, "id"))
    id := getIDFromURL(r)
    deleteTaskByID(id)
    w.WriteHeader(http.StatusOK)
    // Empty response — HTMX removes the element
}

Testing Stdlib Handlers

// handlers/tasks_test.go
package handlers

import (
    "net/http"
    "net/http/httptest"
    "net/url"
    "strings"
    "testing"
)

func TestTaskList_FullPage(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
    rec := httptest.NewRecorder()

    TaskList(rec, req)

    res := rec.Result()
    if res.StatusCode != http.StatusOK {
        t.Errorf("expected 200, got %d", res.StatusCode)
    }

    body := rec.Body.String()
    if !strings.Contains(body, "<!DOCTYPE html>") {
        t.Error("expected full page layout for non-HTMX request")
    }
}

func TestTaskList_HTMXPartial(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    TaskList(rec, req)

    body := rec.Body.String()
    if strings.Contains(body, "<!DOCTYPE html>") {
        t.Error("HTMX response should not include full page layout")
    }
    if !strings.Contains(body, `id="task-list"`) {
        t.Error("expected task-list partial in response")
    }
}

func TestCreateTask_ValidInput(t *testing.T) {
    form := url.Values{}
    form.Set("title", "My new task")

    req := httptest.NewRequest(http.MethodPost, "/tasks",
        strings.NewReader(form.Encode()))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    CreateTask(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }
    if !strings.Contains(rec.Body.String(), "My new task") {
        t.Error("expected task title in response")
    }
    if strings.Contains(rec.Body.String(), "<!DOCTYPE html>") {
        t.Error("expected partial response, not full page")
    }
}

func TestCreateTask_EmptyTitle(t *testing.T) {
    form := url.Values{}
    form.Set("title", "")

    req := httptest.NewRequest(http.MethodPost, "/tasks",
        strings.NewReader(form.Encode()))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    CreateTask(rec, req)

    if rec.Code != http.StatusUnprocessableEntity {
        t.Errorf("expected 422, got %d", rec.Code)
    }
    if !strings.Contains(rec.Body.String(), "Title is required") {
        t.Error("expected error message in response")
    }
}

Table-Driven Tests for HTMX Handlers

func TestTaskList_TableDriven(t *testing.T) {
    tests := []struct {
        name            string
        isHTMX          bool
        wantFullPage    bool
        wantContains    []string
        wantNotContains []string
    }{
        {
            name:         "full page for direct navigation",
            isHTMX:       false,
            wantFullPage: true,
            wantContains: []string{"<!DOCTYPE html>", "task-list"},
        },
        {
            name:         "partial for HTMX request",
            isHTMX:       true,
            wantFullPage: false,
            wantContains: []string{`id="task-list"`, "hx-delete"},
            wantNotContains: []string{"<!DOCTYPE html>"},
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
            if tc.isHTMX {
                req.Header.Set("HX-Request", "true")
            }
            rec := httptest.NewRecorder()

            TaskList(rec, req)

            body := rec.Body.String()
            for _, want := range tc.wantContains {
                if !strings.Contains(body, want) {
                    t.Errorf("expected %q in response body", want)
                }
            }
            for _, notWant := range tc.wantNotContains {
                if strings.Contains(body, notWant) {
                    t.Errorf("did not expect %q in response body", notWant)
                }
            }
        })
    }
}

Testing with Gin

// gin_handlers/tasks.go
package ginhandlers

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func SetupRouter() *gin.Engine {
    r := gin.New()
    r.GET("/tasks", TaskList)
    r.POST("/tasks", CreateTask)
    r.DELETE("/tasks/:id", DeleteTask)
    return r
}

func TaskList(c *gin.Context) {
    tasks := getTasksFromDB()

    if c.GetHeader("HX-Request") == "true" {
        c.HTML(http.StatusOK, "task-list-partial.html", gin.H{"tasks": tasks})
        return
    }
    c.HTML(http.StatusOK, "task-list.html", gin.H{"tasks": tasks})
}
// gin_handlers/tasks_test.go
package ginhandlers

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/gin-gonic/gin"
)

func setupTestRouter() *gin.Engine {
    gin.SetMode(gin.TestMode)
    return SetupRouter()
}

func TestTaskList_Gin_HTMXPartial(t *testing.T) {
    router := setupTestRouter()

    req, _ := http.NewRequest(http.MethodGet, "/tasks", nil)
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    router.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }
    if strings.Contains(rec.Body.String(), "<!DOCTYPE html>") {
        t.Error("HTMX response should be a partial, not full page")
    }
}

func TestCreateTask_Gin_ValidData(t *testing.T) {
    router := setupTestRouter()

    form := strings.NewReader("title=New+task")
    req, _ := http.NewRequest(http.MethodPost, "/tasks", form)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    router.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }
    if !strings.Contains(rec.Body.String(), "New task") {
        t.Error("expected task title in response")
    }
}

Testing with Chi

// chi_handlers/tasks.go
package chihandlers

import (
    "net/http"
    "github.com/go-chi/chi/v5"
)

func Router() *chi.Mux {
    r := chi.NewRouter()
    r.Get("/tasks", TaskList)
    r.Post("/tasks", CreateTask)
    r.Delete("/tasks/{id}", DeleteTask)
    return r
}
// chi_handlers/tasks_test.go
func TestDeleteTask_Chi(t *testing.T) {
    r := Router()

    // Create a task first
    task := createTestTask("Task to delete")

    req, _ := http.NewRequest(http.MethodDelete, "/tasks/"+strconv.Itoa(task.ID), nil)
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    r.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }
    if rec.Body.Len() != 0 {
        t.Errorf("expected empty body, got %q", rec.Body.String())
    }
}

Testing HTMX Response Headers

func TestCompleteTask_HTMXTrigger(t *testing.T) {
    task := createTestTask("Complete me")

    req := httptest.NewRequest(http.MethodPost,
        "/tasks/"+strconv.Itoa(task.ID)+"/complete", nil)
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    CompleteTask(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }
    
    trigger := rec.Header().Get("HX-Trigger")
    if trigger == "" {
        t.Error("expected HX-Trigger header")
    }
    if !strings.Contains(trigger, "taskCompleted") {
        t.Errorf("expected taskCompleted event in HX-Trigger, got %q", trigger)
    }
}

func TestLoginHandler_HTMXRedirect(t *testing.T) {
    form := url.Values{}
    form.Set("email", "alice@example.com")
    form.Set("password", "secret")

    req := httptest.NewRequest(http.MethodPost, "/login",
        strings.NewReader(form.Encode()))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    LoginHandler(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200 for HTMX login, got %d", rec.Code)
    }
    if rec.Header().Get("HX-Redirect") != "/dashboard" {
        t.Errorf("expected HX-Redirect to /dashboard, got %q", rec.Header().Get("HX-Redirect"))
    }
}

Testing HTML Templates Directly

// templates_test.go
func TestTaskItemPartialTemplate(t *testing.T) {
    tmpl, err := template.ParseFiles("templates/partials/task-item.html")
    if err != nil {
        t.Fatalf("failed to parse template: %v", err)
    }

    task := Task{ID: 1, Title: "Write tests", Completed: false}

    var buf strings.Builder
    if err := tmpl.Execute(&buf, task); err != nil {
        t.Fatalf("template execution failed: %v", err)
    }

    output := buf.String()
    if !strings.Contains(output, "Write tests") {
        t.Error("expected task title in template output")
    }
    if !strings.Contains(output, `hx-delete="/tasks/1"`) {
        t.Error("expected hx-delete attribute with correct URL")
    }
    if !strings.Contains(output, `hx-target="this"`) {
        t.Error("expected hx-target attribute")
    }
}

Testing hx-boost

HTMX's hx-boost intercepts standard <a> and <form> elements and converts them to HTMX requests. For boosted links, HTMX sends HX-Boosted: true in addition to HX-Request: true.

func TestPageHandler_HTMXBoosted(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/about", nil)
    req.Header.Set("HX-Request", "true")
    req.Header.Set("HX-Boosted", "true")
    rec := httptest.NewRecorder()

    AboutPage(rec, req)

    body := rec.Body.String()
    // Boosted requests typically get the inner content without the outer layout
    // to update just the <main> element
    if strings.Contains(body, "<nav>") {
        t.Error("boosted page response should not include navigation")
    }
    if !strings.Contains(body, "About us") {
        t.Error("expected page content in response")
    }
}

Parallel Tests

Go tests run in parallel by default within a package. HTMX handler tests are stateless (assuming mocked or test DB), so they parallelize well:

func TestTaskList_Parallel(t *testing.T) {
    t.Parallel()
    
    req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
    req.Header.Set("HX-Request", "true")
    rec := httptest.NewRecorder()

    TaskList(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }
}

HelpMeTest for HTMX Go Apps

Unit tests with httptest are fast — Go tests run in milliseconds. But HTMX browser behavior — swap transitions, history API, focus management — needs browser testing.

HelpMeTest runs your HTMX interactions in a real browser:

When the user clicks delete on a task
Then the task row disappears with a 300ms fade
And the task is removed from the database
And the task count in the header updates immediately

Summary

Testing HTMX with Go:

  • Use httptest.NewRequest() + httptest.NewRecorder() for all handler tests
  • Set req.Header.Set("HX-Request", "true") to simulate HTMX
  • Check strings.Contains(body, "<!DOCTYPE html>") to verify partial vs full
  • Use rec.Header().Get("HX-Trigger") to assert on HTMX event headers
  • Test hx-boost with both HX-Request and HX-Boosted headers
  • Gin: use router.ServeHTTP(rec, req) for integration testing
  • Chi: same pattern with chi.NewRouter().ServeHTTP(rec, req)
  • Table-driven tests work well for multiple HTMX/non-HTMX combinations

Read more