You are reading the docs for v0.8. Read the latest (v0.10)
Testing
Tests drive a composition through the same HTTP path the server uses, so the router, the origin check, the session cookie and the SSE stream all run. There is no direct method seam: a test asserts on status codes, rendered HTML and stream frames, never on internal state. vt does this in-process with no browser; vtbrowser does it in headless Chromium, for what only a browser can show.
On this page
#Test a page without a browser
type Counter struct{ n *atomic.Int64 }
func (c *Counter) Inc(ctx *via.Ctx) { c.n.Add(1) }
func (c *Counter) Dec(ctx *via.Ctx) { c.n.Add(-1) }
func (c *Counter) View() h.H {
return h.Div(
h.Button(on.Click(c.Dec), h.Str("-")), // action 0
h.P(h.Str("count: "), h.Str(c.n.Load())),
h.Button(on.Click(c.Inc), h.Str("+")), // action 1
)
}
func TestCounter(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Counter{n: new(atomic.Int64)}))
status, body := app.Get("/")
require.Equal(t, 200, status)
assert.Contains(t, body, "count: 0")
status, body = app.Action(1).Fire() // the second action on the page: Inc
require.Equal(t, 200, status)
assert.Contains(t, body, "count: 1") // the Datastar patch for the re-render
}
vt.Serve mounts any http.Handler on an in-memory httptest server and closes it when the test ends. vt.App.Get returns the status and body; a render that panics comes back as a 500, not a transport error.
vt.App.Action addresses an action by position: Action(n) is the n-th action the root renders, in document order. vt reads the action's URL off the rendered page when the action fires, because the wire id is a hash of the handler and cannot be built by hand. vt.Action.Fire returns the status and the body, which on a plain page is the Datastar patch.
#Actions and arguments
func TestStepper_addsThePostedStep(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Stepper{n: new(atomic.Int64)}))
status, body := app.Action(0).Body(`{"step":5}`).Fire()
require.Equal(t, 200, status)
assert.Contains(t, body, "total: 5")
}
vt.Action.Body sets the JSON the browser would post: the page's signals, keyed by wire name. The handler reads them with via.Signal.Get as it would from a real click.
func TestShelf_eachRowCarriesItsOwnArg(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Shelf{titles: []string{"Solaris", "Dune", "Ubik"}}))
_, body := app.Action(1).Fire() // the second row's button
assert.Contains(t, body, `"picked":"Dune"`)
}
An action bound with on.WithArg renders one URL per row, each carrying its own argument. Picking a row by position picks its argument; there is nothing to encode.
type Search struct{ Q via.Signal[string] }
func (s *Search) Go(ctx *via.Ctx) { s.Q.Set("searched: " + s.Q.Get()) }
func (s *Search) View() h.H {
return h.Form(on.Submit(s.Go), h.Input(s.Q.Bind()), h.Button(h.Str("go")))
}
type Layout struct{ Search Search }
func (l *Layout) View() h.H { return h.Main(via.Child(l.Search)) }
func TestLayout_searchChild(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Layout{}))
// "0" is the root's first Child; its signals are prefixed with its field name.
_, body := app.ChildAction("0", 0).Body(`{"search__q":"via"}`).Fire()
assert.Contains(t, body, "searched: via")
}
vt.App.ChildAction does the same inside a child. The key is the child's container: "0" for the root's first via.Child, "1" for its second, "0-1" for the second child inside the first. A child's signals carry its field name and a double underscore.
func TestCounter_refusesCrossSitePosts(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Counter{n: new(atomic.Int64)},
via.WithTrustedOrigin("https://example.com")))
status, _ := app.Action(1).Fire() // same-origin by default
assert.Equal(t, 200, status)
status, _ = app.Action(1).Origin("https://evil.example").Fire()
assert.Equal(t, 403, status)
status, _ = app.Action(1).NoOrigin().Fire()
assert.Equal(t, 403, status)
}
Every action is a same-origin fetch by default. vt.Action.Origin, vt.Action.NoOrigin, vt.Action.SecFetch and vt.Action.Host change the headers the origin check reads. The check is on only with via.WithTrustedOrigin; without it every origin is accepted and the test above would see 200.
func TestCounter_overTLSRefusesAnHTTPOrigin(t *testing.T) {
t.Parallel()
app := vt.ServeTLS(t, via.Handler(Counter{n: new(atomic.Int64)},
via.WithTrustedOrigin("https://example.com")))
status, _ := app.Action(1).Host("app.example").Origin("https://app.example").Fire()
assert.Equal(t, 200, status)
status, _ = app.Action(1).Host("app.example").Origin("http://app.example").Fire()
assert.Equal(t, 403, status) // a scheme downgrade on a TLS request
}
vt.ServeTLS serves over TLS on a loopback listener, so the request carries req.TLS and an http:// Origin counts as a downgrade. Over plain vt.Serve the scheme is not checked, because behind a TLS-terminating proxy it is unknown.
#Live units and streams
type Board struct{ Msg via.State[string] }
func (b *Board) Post(ctx *via.Ctx) { b.Msg.Set("hello") }
func (b *Board) View() h.H {
return h.Div(
h.P(h.Str("msg: "), b.Msg.Display()),
h.Button(on.Click(b.Post), h.Str("post")),
)
}
func TestBoard_pushesOverTheStream(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Board{}))
conn := app.Connect()
status, _ := app.Action(0).Over(conn).Fire()
require.Equal(t, 204, status) // a live action acks; the render rides the stream
conn.Await("msg: hello")
}
vt.App.Connect opens the tab's SSE stream and waits for its tab id. vt.Action.Over routes an action to that connection. A live action answers 204 with no body; the re-render arrives on the stream, where vt.Conn.Await waits up to 2s for a frame line containing the text and returns it. For a page mounted below the root, open the stream with vt.App.ConnectAt.
type Clock struct{ n via.State[int] }
func (c *Clock) OnInit(ctx *via.Ctx) error { ctx.Tick(time.Minute, c.tick); return nil }
func (c *Clock) tick(ctx *via.Ctx) { c.n.Set(c.n.Get() + 1) }
func (c *Clock) View() h.H { return h.P(h.Str("ticks="), c.n.Display()) }
func TestClock_ticksOncePerMinute(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
app := vt.Serve(t, via.Handler(Clock{}))
conn := app.Connect()
time.Sleep(59 * time.Second)
synctest.Wait()
for {
line, ok := conn.Peek()
if !ok {
break
}
assert.NotContains(t, line, "ticks=1")
}
time.Sleep(time.Second)
conn.Await("ticks=1")
})
}
vt.Serve uses an in-memory network, so a live test runs inside synctest.Test and a minute of Tick costs no wall time. vt.Conn.Peek reads a buffered frame without blocking, which is how to assert that something has not arrived yet: an Await would let fake time run until it did.
func TestBoard_shutdownEndsTheStreamCleanly(t *testing.T) {
t.Parallel()
r := via.Handler(Board{})
app := vt.Serve(t, r)
conn := app.Connect()
r.Close()
require.NoError(t, conn.AwaitClose())
}
vt.Conn.AwaitClose waits for the server to end the stream and returns what ended it: nil for a clean close, an error for a truncated one. Use it to test graceful shutdown.
#Two tabs
type Room struct {
bus *topic.Topic[string]
Last via.State[string]
}
func (r *Room) OnInit(ctx *via.Ctx) error { ctx.Listen(r.bus, r.heard); return nil }
func (r *Room) heard(ctx *via.Ctx, s string) { r.Last.Set(s) }
func (r *Room) Wave(ctx *via.Ctx) { r.bus.Publish("wave") }
func (r *Room) View() h.H {
return h.Div(
h.P(h.Str("last: "), r.Last.Display()),
h.Button(on.Click(r.Wave), h.Str("wave")),
)
}
func TestRoom_aPublishReachesEveryTab(t *testing.T) {
t.Parallel()
app := vt.Serve(t, via.Handler(Room{bus: topic.New[string]()}))
alice, bob := app.Connect(), app.Connect()
assert.NotEqual(t, alice.TabID(), bob.TabID())
status, _ := app.Action(0).Over(alice).Fire()
require.Equal(t, 204, status)
alice.Await("last: wave")
bob.Await("last: wave")
}
Each vt.App.Connect is a new tab with its own vt.Conn.TabID. An action over one tab that publishes on a topic.Topic reaches every tab listening on it, so fan-out needs no browser. vt.Action.Tab sets a tab id by hand, for a test that routes an action to the wrong tab on purpose.
#Real browser tests
vtbrowser.Open starts an httptest server for your handler, launches headless Chromium through chromedp, and loads the root. The test then runs against a live DOM with Datastar executing under via's CSP. Use it for what vt cannot see: a click reaching the handler, an SSE patch morphing the DOM, a bound input, focus surviving a patch, the reconnect banner.
| Helper | Does |
|---|---|
vtbrowser.Session.Click, vtbrowser.Session.Type | A real mouse click; real key events, so binds and key handlers fire. |
vtbrowser.Session.WaitTextContains, vtbrowser.Session.WaitValue, vtbrowser.Session.WaitFor | Poll the DOM until it matches, for up to 20s. No sleeps. |
vtbrowser.Session.WaitLiveConnected | Block until the tab's stream is open. |
vtbrowser.Session.WaitBoundSignal | Block until the Datastar signal behind an input holds a value. |
vtbrowser.Session.Eval, vtbrowser.Session.WaitEvalTrue | Run JavaScript for what the other helpers don't read. |
vtbrowser.Session.NewTab | A second tab in the same browser: same cookies, its own stream. |
vtbrowser.Session.Restart | Take the server down for a gap and bring a new handler up on the same port, like a deploy. |
vtbrowser.Session.RequireCleanConsole | Fail on any console.error or uncaught exception. End every browser test with it. |
vtbrowser is its own Go module, so chromedp never enters the dependency graph of via or of your app unless you import it. The via repo's vtbrowser_test.go is a working set of examples.
cd vtbrowser && VIA_CHROME=/usr/bin/chromium go test -race -tags browser ./...
./ci.sh --browser # fail if no browser is found
./ci.sh --chrome=/path/to/chromiumvtbrowser.Open looks for chromium, chromium-browser, chrome, google-chrome or headless-shell on PATH, or uses VIA_CHROME, and skips the test when it finds none. In the via repo the browser tests carry //go:build browser, so plain go test ./... never starts Chromium; ci.sh runs them when a browser is present and warns when one is not.
#What vt does not simulate
vt runs the real server, but Datastar never executes. A green vt test is necessary, not sufficient:
- Client-only signals are sent. A browser never posts a signal whose name starts with an underscore;
vt.Action.Bodyposts whatever you write. A test can pass on input a real page never sends. - No event modifiers or bind coercion.
on.Debounce,on.Throttle, key filters anddata-bindvalue coercion run in the browser. vt posts the action directly. - Frames are text.
vt.Conn.Awaitmatches a substring of one frame line, not a parsed DOM. It cannot assert structure, and it can match a stale frame. - Action URLs come from the root page.
vt.App.Actionreads URLs from a GET of/. For a page mounted elsewhere, take the URL from itsvt.App.Getand pass it tovt.Action.Raw.
For behaviour that depends on the client (client-only signals, modifiers, morphing, reconnect), write a vtbrowser test.
#Conventions
Tests enter through exported symbols (use package foo_test), assert on observable output (status, HTML, stream frames, errors), and call t.Parallel() wherever no mutable state is shared; a synctest.Test test manages its own time and does not. Give each test its own store and topic.Topic in the root literal, as the samples above do, so parallel tests never share state. Prefer real or stub implementations of interfaces you own over mocks; keep mocks for true system boundaries.