Testing
Tests drive the composition through the same HTTP path the server uses, so
the full middleware stack, session cookie, and SSE machinery run end-to-end.
There is no “direct method” seam: assertions hit rendered HTML or SSE frames,
never internal state. The harness lives in via/vt.
import (
"github.com/go-via/via"
"github.com/go-via/via/vt"
)
app := via.New()
srv := vt.Serve(t, app) // httptest server dispatching through App.ServeHTTP
via.Mount[Counter](app, "/")
tc := vt.NewClient(t, srv, "/")
c := &Counter{}
require.Equal(t, 200, tc.Action(c.Inc).Fire()) // typed: typo → compile error
require.Equal(t, 200, tc.Action("Apply"). // string still works
WithSignal("step", 5).Fire())
require.Contains(t, tc.Reload(), ">1<")
frames, cancel := tc.SSE()
defer cancel()
vt.AwaitFrame(t, frames, 2*time.Second, ">3<")
tc.Action(p.Upload).
WithFile("avatar", "me.png", pngBytes).
WithSignal("note", "from CLI").
Fire()
API
vt.NewClient(t, server, path)— performs the initial GET (acquiring thevia_tabid + session cookie on a shared jar) and returns a*Client.tc.Action(target)— accepts a method value (compile-time typo protection) or the action’s name as a string. Chain.WithSignal,.WithFile, then.Fire()(returns the HTTP status).tc.HTML()/tc.Reload()— the initial / re-fetched page body, so post-action body assertions are one call.tc.SSE()/tc.SSEReady()— open the tab’s SSE stream;SSEReadyblocks until the server handshake so there’s no timing guess.vt.AwaitFrame(t, frames, timeout, needles...)— wait until all needles appear across the accumulated frames; returns the matched content.tc.Fork(path)— a second tab on the same cookie jar — the only way to driveStateSessbehaviour that spans tabs.
What vt does not simulate
vt runs the real server, but it is not a browser — Datastar never
executes. A green vt test is necessary, not sufficient. In particular:
- Local (
_-prefixed) signals are sent. A real browser never POSTs a_-prefixed signal to the server;WithSignal("_open", v)does. A test can pass while the in-browser behaviour differs. - No client-side key filter / debounce / bind coercion.
on.Key,on.Debounce, anddata-bindvalue coercion are evaluated by Datastar in the browser.vtposts the action body directly, so it cannot reproduce them. - Frames are matched as raw strings.
AwaitFramedoes a substring match over the accumulated SSE bytes, not a parsed DOM — it cannot assert element structure, and a needle can match a stale frame.
For behaviour that depends on the Datastar client — local signals, key
filters, reconnect/retry, multi-tab cookie races — verify in a real browser
with the vtbrowser harness (below), the anchor for the client-side
guarantees vt cannot reach.
Browser testing (vtbrowser)
The vtbrowser harness drives a via App in a real headless
Chrome/Chromium via chromedp,
asserting against a live DOM with Datastar executing — not raw frame text. It
covers what the DOM-less vt harness structurally cannot: Datastar
expression evaluation, SSE→DOM morph patching, focus preservation across
a patch, the reconnect-banner lifecycle, and behaviour under mw.CSP().
It lives in its own Go module (vtbrowser/, separate go.mod) so the
heavyweight chromedp dependency never touches the core module’s graph.
import "github.com/go-via/via/vtbrowser"
func TestClick(t *testing.T) {
app := via.New(via.WithInsecureCookies()) // httptest serves plain http
via.Mount[Counter](app, "/")
s := vtbrowser.Open(t, app) // starts httptest server + headless browser
s.WaitText("#count", "0")
s.Click("#inc")
s.WaitText("#count", "1")
assert.Empty(t, s.ConsoleErrors()) // a clean DOM with a broken console is not a pass
}
Session helpers: Click, Type, WaitText (polls — absorbs SSE latency
without sleeps), Eval (escape hatch for focus/attr/value asserts),
SetOffline (CDP network emulation for outage tests), Server() (the
underlying *httptest.Server, e.g. CloseClientConnections() to drop a live
SSE stream), and ConsoleErrors() (every console.error + uncaught
exception — every test asserts it’s empty).
Skips without a browser; CI cannot
Open resolves a browser binary on PATH (chrome, chromium,
chromium-browser, google-chrome, headless-shell). With none found it
skips, so a checkout without Chrome stays green — unless
VIA_BROWSER_REQUIRED=1, which turns the skip into a hard failure so CI
cannot silently pass an unrun suite.
Running locally
cd vtbrowser && go test -race ./...
# or, the way CI runs it (skip becomes failure):
./ci-check.sh --browser
CI runs vtbrowser in a dedicated job with Chromium installed; the default
go test ./... over the core module never reaches it (it’s a separate
module).
Conventions
Tests enter through exported symbols (use package foo_test), assert on
observable output (HTML / SSE frames / status / errors), and call
t.Parallel() wherever there’s no shared mutable state. Use real or stub
implementations over mocks for interfaces you own; reserve mocks for true
system boundaries.