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 immediatelySummary
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-boostwith bothHX-RequestandHX-Boostedheaders - 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