Browser Tests
Browser tests exercise the parts of your app that only exist in a real browser, by driving a live server with Playwright and asserting on what the user sees.
Some behavior only exists in a real browser — Flow's WebSocket bridge and Alpine runtime, Inertia client navigation, focus and keyboard handling. This page covers Playwright pointed at a running server, which is what to use for cross-browser end-to-end coverage.
Testing Flow's bridge?
FlowBrowserships in@zerotal/testing, needs no extra dependency, and runs insidebun testalongside the rest of your suite. It drives a headless page against a real server and can assert on the WebSocket transport itself — whether the socket upgraded, what frames crossed the wire. Reach for Playwright when you need several browsers, or coverage beyond Flow.
Note — Unit and HTTP tests run on Bun's test runner. Browser tests run under Playwright's own runner (
*.e2e.ts/*.spec.ts), separately frombun test.
Setup
Install Playwright in your app:
# in your project root
bun add -d @playwright/test
bunx playwright install --with-deps
Add a playwright.config.ts that boots your app with the webServer option, so the
server starts once for the whole run and is torn down after:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
const PORT = 3000;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.e2e.ts",
timeout: 30_000,
use: { baseURL: BASE_URL, trace: "on-first-retry" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
// Boot the server once for the run (no --hot, to avoid reload flakiness)
webServer: {
command: "bun run start",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Wire up scripts in package.json:
// package.json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
Writing a test
Specs live in e2e/ and use Playwright's test/expect. Drive the page through
roles and assert on what the user sees:
// e2e/navigate.e2e.ts
import { test, expect } from "@playwright/test";
test.describe("SPA navigation", () => {
test("swaps content without a full page reload", async ({ page }) => {
await page.goto("/demo/counter");
// A full reload would clear this marker; an SPA swap keeps it.
await page.evaluate(() => ((window as any).__spa = true));
await page.getByRole("link", { name: "Components" }).click();
await expect(page).toHaveURL(/\/demo\/components/);
await expect(page.getByRole("heading", { name: "Native Components" })).toBeVisible();
const kept = await page.evaluate(() => (window as any).__spa);
expect(kept).toBe(true); // no full reload happened
});
});
# in your project root
bun run test:e2e
Waiting for the client to boot
Flow pages finish wiring once Alpine has walked the DOM and fired
alpine:initialized. A fast click can land before that and silently no-op (a flaky,
browser-dependent failure). Wait for readiness before interacting:
// e2e/support/gotoReady.ts
import type { Page } from "@playwright/test";
export async function gotoReady(page: Page, url: string): Promise<void> {
await page.addInitScript(() => {
(window as any).__alpineReady = false;
document.addEventListener("alpine:initialized", () => ((window as any).__alpineReady = true));
});
await page.goto(url);
await page.waitForFunction(() => (window as any).__alpineReady === true, null, {
timeout: 10_000,
});
}
Then await gotoReady(page, "/demo/counter") instead of page.goto(...) for
interactive Flow pages.
Warning — Clicking before
alpine:initializedfires is the most common source of browser-test flakiness. UsegotoReady(or an equivalent readiness wait) on any page driven by Flow's Alpine runtime.
Tips
- Seed deterministic state. If a flow needs data, seed it before the run (or use pages backed by in-memory state). Browser tests don't share the transactional rollback that database tests use.
- Prefer role/label selectors (
getByRole,getByLabel) over CSS — they're resilient to markup changes and assert accessibility. - Run cross-browser in CI. The three projects above cover Chromium, Firefox, and WebKit; enable retries on CI to absorb transient flakiness.
Next steps
- HTTP Tests — faster tests for anything not needing a browser.
- Database Tests — seed and roll back data for backed flows.
- Flow / Inertia — the client runtimes these tests exercise.
- Deployment —
bun run start, the command thewebServerboots.