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 build of the project. Inspect the harness output or set WithSkipBuild(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, and WaitStable over time.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