How to write browser tests
This guide walks through end-to-end browser tests with wdk/browser. Tests drive a real Chromedp browser against a built Piko binary, so they exercise the full request path (server, actions, client components). For the full method surface see browser testing harness reference. For the comparison with pikotest AST tests see about browser testing.
Set up the harness in TestMain
The harness compiles the project once and shares the browser across every test in the package. Create TestMain:
package browsertests
import (
"os"
"testing"
"time"
"piko.sh/piko/wdk/browser"
)
func TestMain(m *testing.M) {
harness := browser.NewHarness(
browser.WithProjectDir("../.."),
browser.WithOutputDir("./testdata/output"),
browser.WithBuildTimeout(2 * time.Minute),
)
if err := harness.Setup(); err != nil {
harness.Cleanup()
os.Exit(1)
}
code := m.Run()
harness.Cleanup()
os.Exit(code)
}
Setup builds the binary, starts the server on a free port, and boots headless Chrome. Cleanup shuts everything down.
Write a first test
func TestLoginFlow(t *testing.T) {
page := browser.New(t)
page.Navigate("/login")
page.Fill("[name=email]", "[email protected]")
page.Fill("[name=password]", "hunter2")
page.Click("button[type=submit]")
page.WaitForText("h1", "Welcome, Alice")
page.Assert("h1").Text("Welcome, Alice")
page.AssertNoConsoleErrors()
}
browser.New(t) returns a page bound to the test. Failures call t.Fatalf so the first failed assertion stops the test.
Wait for the page to settle
Piko pages typically load fast. Interactive flows with client components sometimes need explicit synchronisation:
page.WaitForVisible(".product-card") // element appears in the DOM
page.WaitForText("h1", "Products") // text content matches
page.WaitForNetworkIdle(500 * time.Millisecond) // 500 ms with no in-flight requests
page.WaitStable() // page reaches a stable state
Wait accepts a duration directly (it sleeps for that long), and WaitFor polls for a selector to appear. WaitForNetworkIdle(idleDuration, opts...) requires the idle window. Pass WaitOptions (WithTimeout, etc.) to the polling waits as varargs:
page.Wait(2 * time.Second) // sleep for 2 seconds
page.WaitFor(".slow-load") // poll until the selector appears
page.WaitForVisible(".slow-load", browser.WithTimeout(30 * time.Second))
Test a form submission
func TestContactForm(t *testing.T) {
page := browser.New(t)
page.Navigate("/contact")
page.Fill("[name=name]", "Alice Smith")
page.Fill("[name=email]", "[email protected]")
page.Fill("textarea[name=message]", "Hello from a test")
page.Click("button[type=submit]")
page.WaitForText(".toast", "Message sent")
page.Assert(".toast").HasClass("success")
}
Emulate a device
Viewport and user-agent presets ship for common devices:
func TestMobileLayout(t *testing.T) {
page := browser.New(t)
page.EmulateIPhone14()
page.Navigate("/")
page.Assert(".mobile-menu").IsVisible()
page.Assert(".desktop-nav").IsVisible().Count(0)
}
Presets include EmulateIPhone13, EmulateIPhone14Pro, EmulateIPad, EmulateIPadPro, EmulateGalaxyS9, EmulateDesktop4K, EmulateDesktopHD. ResetEmulation() restores the default viewport.
Intercept network requests
InterceptRequest(urlPattern, response) takes a browser_provider_chromedp.MockResponse value, but that package lives under wdk/browser/internal/ and Go's internal-package rule prevents external code from constructing the value directly. Today the only callers of InterceptRequest sit inside the wdk/browser tree itself. For application-level tests, drive the front end through real fixtures or stand up a dedicated test backend instead. Piko exposes a public mock-response type once the surface stabilises.
RemoveRequestIntercept(urlPattern) and ClearRequestIntercepts() are usable from external tests for the matching teardown work when Piko-internal helpers register intercepts.
Drive Piko-specific behaviour
For pages that use the event bus or partial refresh, the page exposes helpers that reach into Piko's client runtime:
page.PikoSetupEventLog()
page.Click(".chat-input [type=submit]")
page.PikoBusWaitForEvent("chat:message-sent", browser.WithTimeout(5*time.Second))
messages := page.PikoGetEventLog()
if len(messages) != 1 {
t.Fatalf("expected one bus event, got %d", len(messages))
}
PikoBusWaitForEvent(eventName, opts...) returns the captured event detail as map[string]any. Use browser.WithTimeout to extend the default five-second wait.
Partial refresh:
page.TriggerPartialReload("product-list", map[string]any{"category": "books"})
page.PikoWaitForPartialReload("product-list", browser.WithTimeout(5*time.Second))
TriggerPartialReload(name, data) takes the partial name plus a payload map. PikoWaitForPartialReload(name, opts...) accepts WaitOption values for the timeout.
Lock regressions with goldens
MatchGolden(selector, name) captures the normalised HTML of the selected element and compares it against a stored snapshot on later runs. It is a markup-level check, not a pixel screenshot:
func TestDashboardLook(t *testing.T) {
page := browser.New(t)
page.Navigate("/dashboard")
page.WaitForNetworkIdle(500 * time.Millisecond)
page.MatchGolden("body", "dashboard")
}
Golden files live under testdata/golden/<name>.html relative to the test working directory. To create or update them, set the PIKO_UPDATE_GOLDEN environment variable:
PIKO_UPDATE_GOLDEN=1 go test ./...
Inside a spec, the same behaviour is available through the WithUpdateGoldens option:
browser.RunSpec(t, "testdata/dashboard.spec.json",
browser.WithUpdateGoldens(true))
Write a declarative spec
For flows with no test-specific Go logic, specs are faster to write and review. Create testdata/login.spec.json:
[
{ "action": "navigate", "target": "/login" },
{ "action": "fill", "selector": "[name=email]", "value": "[email protected]" },
{ "action": "fill", "selector": "[name=password]", "value": "hunter2" },
{ "action": "click", "selector": "button[type=submit]" },
{ "action": "wait_for_text", "selector": "h1", "value": "Welcome" },
{ "action": "assert_text", "selector": "h1", "value": "Welcome" },
{ "action": "match_golden", "name": "login-success" }
]
Run it:
func TestLoginSpec(t *testing.T) {
browser.RunSpec(t, "testdata/login.spec.json")
}
The spec vocabulary mirrors the Page API. See browser testing harness reference for the full action list.
Debug a failing test interactively
Pass -interactive and the harness pauses at each step with a TUI:
go test -run TestLoginFlow -interactive
-headed opens a visible browser window without the TUI. Combine with -headed for debugging CSS without the step-through UI.
Inside a test, page.Pause() drops into the interactive UI on demand.
Capture screenshots and PDFs
page.SaveScreenshot(".login-card", "login-card.png")
page.SaveScreenshotViewport("login-viewport.png")
page.SavePDF("login.pdf")
SaveScreenshot(selector, path) captures a single element. For the full viewport use SaveScreenshotViewport(path). Outputs go under the harness's WithOutputDir directory.
Troubleshoot
- Build failures. The harness runs
go buildof the project. Inspect the harness output or setWithSkipBuild(true)to reuse an existing binary. - Port conflicts.
WithPort(0)picks a free port. Use an explicit port only when the test depends on a specific URL. - Flaky waits. Prefer
WaitForVisible,WaitForText, andWaitStableovertime.Sleep. Waits poll up to their timeout and return as soon as the condition holds. - Missing Chrome. Chromedp shells out to a local Chrome binary. Install Chrome or Chromium on the machine running tests.
See also
- Browser testing harness reference for the complete Page, Harness, and Spec API.
- About browser testing for when to reach for browser tests versus pikotest unit tests.
- Testing API reference for the AST-level pikotest surface.
- How to testing for pikotest recipes.