via

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

counter.go
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
	)
}
counter_test.go
func TestCounter_incrementsOnClick(t *testing.T) {
	t.Parallel()
	app := vt.Serve(t, via.Handler(Counter{n: new(atomic.Int64)}, via.WithLogger(vt.Logger(t))))

	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.Logger, passed to via.WithLogger, sends via's warnings to the test's own log, which go test prints only when the test fails or runs with -v. Without it they reach stderr on every run, the via.WithTrustedOrigin warning once per router.

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

stepper_test.go
func TestStepper_addsThePostedStep(t *testing.T) {
	t.Parallel()
	app := vt.Serve(t, via.Handler(Stepper{n: new(atomic.Int64)}, via.WithLogger(vt.Logger(t))))

	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.

shelf_test.go
func TestShelf_eachRowCarriesItsOwnArg(t *testing.T) {
	t.Parallel()
	app := vt.Serve(t, via.Handler(Shelf{titles: []string{"Solaris", "Dune", "Ubik"}},
		via.WithLogger(vt.Logger(t))))

	_, body := app.Action(1).Fire() // the second row's button
	assert.Contains(t, body, `"picked":"Dune"`)
}

An action bound with on.Bind renders one URL per row, each carrying its own argument. Picking a row by position picks its argument; there is nothing to encode.

layout_test.go
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_routesTheChildActionToSearch(t *testing.T) {
	t.Parallel()
	app := vt.Serve(t, via.Handler(Layout{}, via.WithLogger(vt.Logger(t))))

	// "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.

counter_test.go
func TestCounter_refusesCrossSitePosts(t *testing.T) {
	t.Parallel()
	app := vt.Serve(t, via.Handler(Counter{n: new(atomic.Int64)},
		via.WithTrustedOrigin("https://example.com"), via.WithLogger(vt.Logger(t))))

	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.

counter_test.go
func TestCounter_overTLSRefusesAnHTTPOrigin(t *testing.T) {
	t.Parallel()
	app := vt.ServeTLS(t, via.Handler(Counter{n: new(atomic.Int64)},
		via.WithTrustedOrigin("https://example.com"), via.WithLogger(vt.Logger(t))))

	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

board_test.go
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{}, via.WithLogger(vt.Logger(t))))
	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.

Each line is read once, so a line an earlier Await matched or skipped is gone. When Await gives up it reports what did arrive: the frames on the stream, how many this wait read, and the last few. A wait that read none means the frame it wanted, if it came, went to an earlier Await.

go test
--- FAIL: TestBoard_pushesOverTheStream (2.00s)
    board_test.go:27: vt.Await: timed out after 2s waiting for "msg: bye". 2 frames on this stream, 1 read by this wait; the last 2:
          #1 datastar-patch-signals: signals {"viatab":"eVelnHi8QTPIgFoyF8h8lwM3keTgszM2nqGXa7d_ee4w"}
          #2 datastar-patch-elements: elements <div id="root"><div><p>msg: hello</p><button data-on:click="@post('/_via/a/r/n4Ip3BxF')">post</button></div></div>
clock_test.go
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{}, via.WithLogger(vt.Logger(t))))
		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.

board_test.go
func TestBoard_shutdownEndsTheStreamCleanly(t *testing.T) {
	t.Parallel()
	r := via.Handler(Board{}, via.WithLogger(vt.Logger(t)))
	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

room_test.go
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]()}, via.WithLogger(vt.Logger(t))))
	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.

#The wire protocol

vt sends the same HTTP a browser does, and so can curl or a client in another language. Paths below are under the page's mount, so a page at /thread/{id} streams from /thread/7/_via/sse.

  • GET the page. Action URLs are in its markup: @post('/_via/a/<child>/<id>') in a data-on: attribute, or a via.PostForm's action. The id is a hash of the handler, so read it off the page. A row's argument rides along as ?a=, and a child's instance as ?u=. A binding with a query carries it in a data-via-q-<event> attribute on the same element, and the expression appends it to the path: data-via-q-click="?a=2" data-on:click="@post('/_via/a/r/<id>' + (el.getAttribute('data-via-q-click') ?? ''))" posts to /_via/a/r/<id>?a=2.
  • POST /_via/sse to open the stream. The body is the page's signals as JSON; {} or an empty body will do. The answer is a text/event-stream that stays open: datastar-patch-signals and datastar-patch-elements events, and a keepalive comment every 25 seconds. A page with no live unit answers 404.
  • POST an action. With Datastar-Request: true the body is JSON: the signals keyed by wire name, as Datastar posts its store. Without that header the body is read as a native form, multipart/form-data with the tab id in a _viatab field, as PostForm sends it; a JSON body without the header answers 400 and names the header. A plain unit answers 200 with the patched HTML, or 204 when nothing changed. A live action answers 204 and its render arrives on the stream.

A live page carries its tab id in the document, <body data-signals='{"viatab":"<id>"}'>. Send it in the connect body and the stream adopts it; with none, as in the sample below, or one this process did not issue to this cookie, the stream mints a fresh one. Either way its first event is a datastar-patch-signals frame carrying the id it answers to, signals {"viatab":"<id>"}. Echo it as the viatab signal in every action body and the action routes to that stream. A live page's action without it answers 410, and so does one whose stream has closed and not come back; see Connection lifecycle.

Every POST passes the origin check first; a browser's same-origin fetch sends Sec-Fetch-Site: same-origin, so send it too. See Origin checks and CSRF.

shell
B=http://localhost:3000
H=(-H 'Sec-Fetch-Site: same-origin')

curl -sN -X POST "$B/_via/sse" "${H[@]}" -d '{}' > tab.sse &
sleep 0.2
TAB=$(grep -o '"viatab":"[^"]*"' tab.sse | cut -d'"' -f4)

ADD=$(curl -s "$B/" | grep -o "/_via/a/r/[^']*" | head -1)
curl -s -X POST "$B$ADD" "${H[@]}" -H 'Datastar-Request: true' \
  -H 'Content-Type: application/json' -d "{\"draft\":\"milk\",\"viatab\":\"$TAB\"}"
cat tab.sse

#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.

HelperDoes
vtbrowser.Session.Click, vtbrowser.Session.TypeA real mouse click; real key events, so binds and key handlers fire.
vtbrowser.Session.WaitTextContains, vtbrowser.Session.WaitValue, vtbrowser.Session.WaitForPoll the DOM until it matches, for up to 20s. No sleeps.
vtbrowser.Session.WaitLiveConnectedBlock until the tab's stream is open.
vtbrowser.Session.WaitBoundSignalBlock until the Datastar signal behind an input holds a value.
vtbrowser.Session.Eval, vtbrowser.Session.WaitEvalTrueRun JavaScript for what the other helpers don't read.
vtbrowser.Session.NewTabA second tab in the same browser: same cookies, its own stream.
vtbrowser.Session.RestartTake the server down for a gap and bring a new handler up on the same port, like a deploy.
vtbrowser.Session.RequireCleanConsoleFail 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/chromium

vtbrowser.Open looks for chromium, chromium-browser, chrome, google-chrome, google-chrome-stable 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.Body posts whatever you write. A test can pass on input a real page never sends.
  • No event modifiers or bind coercion. on.WithDebounce, on.WithThrottle, key filters and data-bind value coercion run in the browser. vt posts the action directly.
  • Frames are text. vt.Conn.Await matches 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.Action reads URLs from a GET of /. For a page mounted elsewhere, take the URL from its vt.App.Get and pass it to vt.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.

A package main cannot be imported, so there is no main_test package to test it from. Either write the tests in package main and still enter only through the handler, as vt does, or move the compositions into a package of their own and keep main to the router and the server. The second makes package foo_test possible, and there the compiler stops a test from reaching an unexported field.