Testing
Test GO(A)HT apps by interaction mode, not by framework habit.
Test GO(A)HT apps by the way the browser interacts with them. A page load, an HTMX fragment refresh, an Alpine island JSON call, and an SSE-triggered update are different contracts. Test each contract at the layer that proves it.
The useful split is:
- service and domain tests
- handler tests by response surface
- browser tests by interaction mode
Service And Domain Tests
Services own state transitions and workflow rules. Test them without involving a browser.
func TestGameService_FinishGame(t *testing.T) {
svc := newTestGameService(t)
game := createLiveGame(t, svc)
err := svc.FinishGame(context.Background(), game.ID)
if err != nil {
t.Fatalf("finish game: %v", err)
}
got, err := svc.GetGameByID(context.Background(), game.ID)
if err != nil {
t.Fatalf("reload game: %v", err)
}
if got.Status != "finished" {
t.Fatalf("status = %q, want finished", got.Status)
}
}
This is the right place to test:
- scoring rules
- lifecycle transitions
- authorization decisions inside services
- transaction behavior
Handler Tests By Response Surface
Handlers should be tested according to what they return.
Page HTML
Full-page handlers should assert status code and key document content:
func TestShowGamePage(t *testing.T) {
req := httptest.NewRequest("GET", "/games/abc123", nil)
req.SetPathValue("id", "abc123")
w := httptest.NewRecorder()
h.Pages.ShowGame(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), "<main") {
t.Fatal("expected page shell")
}
}
Partial HTML
Fragment handlers should assert the fragment contract, not the whole page:
func TestScoreboardPartial(t *testing.T) {
req := httptest.NewRequest("GET", "/games/abc123/scoreboard", nil)
req.SetPathValue("id", "abc123")
w := httptest.NewRecorder()
h.Partials.Scoreboard(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), `<section class="scoreboard">`) {
t.Fatal("expected scoreboard fragment root")
}
}
JSON For Alpine Islands
JSON handlers should assert shape and semantics for the island they serve:
func TestGameStateJSON(t *testing.T) {
req := httptest.NewRequest("GET", "/api/games/abc123/state", nil)
req.SetPathValue("id", "abc123")
w := httptest.NewRecorder()
h.API.GameState(w, req)
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("content-type = %q", ct)
}
}
SSE
SSE tests should stay deterministic. A simple pattern is to factor event formatting into a helper and test that helper directly:
func TestWriteSSEEvent(t *testing.T) {
w := httptest.NewRecorder()
writeSSEEvent(w, "refresh-scoreboard", "changed")
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
t.Fatalf("content-type = %q", got)
}
if body := w.Body.String(); body != "event: refresh-scoreboard\ndata: changed\n\n" {
t.Fatalf("body = %q", body)
}
}
That covers header and framing behavior without racing a goroutine against httptest.ResponseRecorder.
Browser Tests By Interaction Mode
End-to-end tests should follow the actual UI mode, not a generic SPA checklist.
Full-Page Navigation
If the flow is normal navigation, test it like navigation:
test('user can open the game page', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Open game' }).click()
await expect(page).toHaveURL(/\/games\/[a-z0-9]+$/)
await expect(page.getByRole('heading', { name: 'Game' })).toBeVisible()
})
HTMX Fragment Flows
If the flow swaps a fragment, assert the fragment result:
test('scoreboard refreshes after an HTMX action', async ({ page }) => {
await page.goto('/games/abc123')
await page.getByRole('button', { name: 'Refresh scoreboard' }).click()
await expect(page.locator('[data-testid="score-home"]')).toHaveText('12')
})
Alpine Island Flows
If the page has one Alpine island, test the optimistic or local-state behavior that justified Alpine in the first place.
SSE-Driven Flows
If the page updates through SSE, verify the browser-observable result, not the wire protocol.
Boosted Apps Need Different E2E Expectations
hx-boost changes how navigation happens, but it does not turn the app into a client-rendered router.
For boosted flows:
- assert visible content after navigation, not just hard reload behavior
- expect normal URLs to remain meaningful and shareable
- do not assume every click creates a full document request
- verify progressive enhancement still works when a page is loaded directly
In practice, that means at least one browser test should open the destination URL directly, even if users usually reach it through boosted navigation.
test('boosted detail page also works as a direct load', async ({ page }) => {
await page.goto('/games/abc123')
await expect(page.getByRole('heading', { name: 'Game' })).toBeVisible()
})
Selector Contracts
Browser tests need stable selectors. Treat them as a contract, not as incidental styling details.
Prefer:
getByRole(...)for semantic controls and headingsgetByLabel(...)for form fieldsdata-testidfor repeated or non-semantic surfaces that need stability
Avoid selectors that depend on styling or document trivia:
- Tailwind classes
- DOM position like
nth-child - text fragments that are likely to change for copy edits
Good:
<section id="scoreboard" data-testid="scoreboard">
<span data-testid="score-home">{{ .Home.Score }}</span>
<span data-testid="score-away">{{ .Away.Score }}</span>
</section>
Bad:
page.locator('.text-4xl.font-bold').nth(0)
Once a selector appears in tests, preserve it intentionally when refactoring templates.
Test What The User Sees, Not The Implementation Detail
For HTMX and boosted navigation, the important contract is:
- the right target updates
- the right content appears
- the URL and history behavior still make sense
For Alpine islands, the important contract is:
- local state responds correctly
- server truth reconciles correctly
For SSE, the important contract is:
- clients show the updated state after the event
That focus keeps tests aligned with GO(A)HT’s actual surfaces instead of importing SPA testing habits that do not match the stack.
Gotchas
- Do not write all browser tests as if every screen were a hard reload. Boosted navigation changes that expectation.
- Do not anchor E2E tests to Tailwind classes. Use selector contracts.
- Fragment handlers deserve handler tests of their own; page tests are not enough.
- If an Alpine island exists only for latency-sensitive behavior, make sure at least one test proves that behavior.