Skip to content

Testing live handlers

A handler’s contract is “given this action, these patches come out.” livetest drops the HTTP transport (no server, no SSE parsing) and keeps that contract.

import (
"github.com/bmartel/alacris-go/live"
"github.com/bmartel/alacris-go/live/livetest"
)
func TestAddCard(t *testing.T) {
srv := live.New()
defer srv.Close()
registerHandlers(srv) // the code under test, exactly as production wires it
sess, rec := livetest.NewSession(t, srv)
err := livetest.Invoke(t, sess, "add-card", "board", components.BoardAddDetail{Text: "Cut the trailer", Column: "todo"})
if err != nil {
t.Fatal(err)
}
p, ok := rec.Last("board", "items")
if !ok {
t.Fatal("add-card did not patch the items prop")
}
items := p.Value.([]todo.Item)
if len(items) != 1 || items[0].Text != "Cut the trailer" {
t.Errorf("items = %v", items)
}
}

Three moving parts:

  • livetest.NewSession mints a session the way a page render would and attaches a Recorder in place of the browser.
  • livetest.Invoke runs the registered handler (session-scoped or server-wide, the same lookup an incoming POST uses) with the detail marshalled for you.
  • rec holds every patch the handler sent. Last(id, key) answers the question a test usually asks: what would the page be showing?

Everything is synchronous. A Send inside a handler has reached the recorder by the time Invoke returns. No polling, no sleeps, no eventual consistency.

OnOpen runs when a browser attaches, so the test keeps the real order: render first, register, then attach.

sess := livetest.RenderSession(t, srv)
sess.OnOpen(func(s *live.Session) { pushAll(s) }) // the code under test
rec := livetest.Attach(t, sess) // "the browser connects"
if _, ok := rec.Last("board", "items"); !ok {
t.Error("OnOpen did not restate the board")
}

Patches sent between RenderSession and Attach are buffered and recorded too, exactly as the pre-connect buffer behaves in production.

Recorder.Patches flattens everything; Recorder.Frames keeps frame boundaries, and a frame is what lands on the page in one paint. To assert that a handler’s writes arrive together, assert on frames:

frames := rec.Frames()
if len(frames) != 1 {
t.Errorf("the list and the count arrived in %d paints, want 1", len(frames))
}

Invoke returns whatever the handler returned, so error paths are plain Go:

err := livetest.Invoke(t, sess, "move-card", "", components.BoardMoveDetail{Column: ""})
if err == nil {
t.Fatal("an empty column must be rejected")
}

An action nobody registered is live.ErrNoAction, which catches the rename that only happened on one side:

if !errors.Is(livetest.Invoke(t, sess, "add-cards", "", nil), live.ErrNoAction) { ... }

livetest proves the right patches leave the server. It cannot prove that applying them leaves focus, scroll position and typed text alone. Those claims live in the Playwright suite under e2e/, which drives the real example app in a real DOM. Test handler logic here; test DOM identity there.