Skip to content

Testing

Browser Tests

Browser tests drive a real Chromium through Playwright. They click buttons, type into fields, wait for JavaScript to settle, and assert on the page a user would actually see. The base class wheels.wheelstest.BrowserTest wires Playwright’s lifecycle into the standard BDD runner, so specs stay short — a few lines of setup, then the fluent DSL takes over.

You’ll learn:

  • How to install Playwright and run your first browser spec
  • The BrowserTest base class and the browserDescribe wrapper
  • The full BrowserClient DSL — navigation, interaction, assertions, terminals
  • How to bypass the login form with the loginAs fixture
  • Cross-engine caveats (dialogs are Lucee-only)

Browser tests require Playwright’s Java client plus Chromium. The wheels browser setup command downloads both (~370MB one-time) and verifies SHA-256 hashes.

Terminal window
wheels --version

Then, inside your project:

shell
wheels browser setup

The installer resolves seven JARs from Maven Central (Playwright client, driver, driver-bundle, transitive deps) and caches them under a per-project install directory. Subsequent runs skip the download when hashes match. Pass --force to re-download. Chromium is the only engine the browser DSL wires today — Firefox and WebKit are not yet selectable.

If Playwright isn’t installed when a spec runs, BrowserTest.beforeAll catches the missing-JAR error, sets this.browserTestSkipped = true, and the suite stays green. That’s the safety net for CI cold starts and fresh dev machines. Inside browserDescribe() blocks the skip is automatic — its aroundEach returns early when the flag is set, so specs don’t need a hand-written guard. Only it blocks inside a plain describe() need to check this.browserTestSkipped themselves.

tests/specs/browser/HomeBrowserSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
this.browserEngine = "chromium";
function run() {
browserDescribe("Home page", () => {
it("loads the home page", () => {
this.browser
.visitUrl("http://localhost:8080/")
.assertTitleContains("My App");
});
});
}
}

Two things to note in every browser spec:

  1. extends="wheels.wheelstest.BrowserTest" — not wheels.WheelsTest. The browser base class adds the Playwright lifecycle hooks on top of BDD.
  2. Use browserDescribe(), not plain describe(). When Playwright JARs aren’t installed, or when the CI gate (WHEELS_CI=true without WHEELS_BROWSER_CI_ENABLE=true) is active, beforeAll sets this.browserTestSkipped — and browserDescribe’s aroundEach skips each it automatically. If you put browser it blocks inside a plain describe() instead, you must guard them yourself with if (this.browserTestSkipped) return; or they crash on the first this.browser call.

Regular describe() works inside a BrowserTest subclass — but every it inside it would share the same browser state, because beforeEach/afterEach aren’t automatically wired. browserDescribe() wraps describe() with hooks that create a fresh Playwright BrowserContext and Page per it, then tear them down afterwards. Each test gets its own cookies, localStorage, and navigation history.

tests/specs/browser/IsolationSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
function run() {
browserDescribe("Isolated per-it state", () => {
it("starts with no cookies", () => {
this.browser.visitUrl("http://localhost:8080/")
.clearCookies()
.assertSee("Sign in");
});
it("also starts with no cookies", () => {
// Fresh context; the previous test's state is gone.
this.browser.visitUrl("http://localhost:8080/")
.assertSee("Sign in");
});
});
}
}

browserDescribe also registers an aroundEach that captures a screenshot and HTML dump to tests/_output/browser/ when a spec throws. Override this.browserArtifactPath to change the directory, or set this.browserScreenshotOnFailure = false to disable capture.

this.browser is a BrowserClient. Every method that mutates state returns this, so calls chain fluently until you hit a terminal (a method that returns a value — currentUrl(), text(), etc.).

  • visit(path) — navigate to a path joined to the base URL, resolved at instance time through a layered lookup: this.baseUrl (per-spec override) → get("browserTestBaseUrl") (Wheels setting) → JVM system property wheels.browserTest.baseUrlWHEELS_BROWSER_TEST_BASE_URL env var → CGI auto-detect → http://localhost:8080 default. Path must start with /.
  • visitUrl(url) — navigate to an absolute URL. Use for data:, file:, or cross-origin tests.
  • visitRoute(route, key, params) — resolve a named route via URLFor() and navigate there. Requires the Wheels app to be booted.
  • back() / forward() / refresh() — drive the browser history.
tests/specs/browser/NavigationSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
function run() {
browserDescribe("Navigation", () => {
it("follows a route and comes back", () => {
this.browser
.visitRoute(route="posts")
.click("a.post-link")
.back()
.assertUrlContains("/posts");
});
});
}
}
  • click(selector) — click the first matching element.
  • press(buttonText) — click the first element containing that visible text. Shorthand for buttons and links by label.
  • fill(selector, value) — set an input’s value in one shot (fires change at the end).
  • type(selector, value) — type character-by-character. Use when per-keystroke handlers matter (autosuggest, live validation).
  • clear(selector) — empty a field.
  • select(selector, value) — select an option by value.
  • check(selector) / uncheck(selector) — checkboxes and radios.
  • attach(selector, filePath) — attach a file to a <input type="file">.
  • dragAndDrop(fromSelector, toSelector) — drag one element onto another.
  • keys(selector, key) — press a keyboard key against the matched element. Key syntax follows Playwright: "Enter", "Control+A", "Shift+Home".
  • pressEnter(selector?) / pressTab(selector?) / pressEscape(selector?) — shortcuts. Omit the selector to send to the currently focused element.

Explicit waits beat sleeps. All waits default to 30-second timeouts and accept a custom seconds override.

  • waitFor(selector, seconds?) — wait for an element to become visible.
  • waitForText(text, seconds?) — wait for visible text to appear anywhere on the page.
  • waitForUrl(url, seconds?) — wait for the page URL to match. Supports Playwright’s glob patterns.

within(selector, callback) restricts the selectors inside the callback to descendants of the first element matching selector. Useful when the same field selector exists in two places (main form and modal, for example).

tests/specs/browser/ScopedSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
function run() {
browserDescribe("Scoped form", () => {
it("fills the signin form, not the signup form", () => {
this.browser
.visitUrl("http://localhost:8080/sign-in")
.within("form##signin", (scoped) => {
scoped.fill("##email", "alice@example.com")
.fill("##password", "correct-horse-battery-staple")
.click("button[type=submit]");
})
.assertUrlContains("/dashboard");
});
});
}
}
  • setCookie(name, value, url) — add a cookie to the context. Requires the page to have been navigated to a real HTTP origin (data: URLs don’t take cookies).
  • deleteCookie(name) — remove one cookie.
  • cookie(name) — read a cookie; returns a struct with name, value, domain, path, expires, httpOnly, secure. Throws BrowserAssertionFailed if the cookie doesn’t exist.
  • clearCookies() — wipe all cookies.

loginAs(identifier) takes a single string (email, username, or whatever your app uses to identify a user) and navigates to /_browser/login-as?identifier=.... In your own app that route is mounted when set(loadBrowserTestFixtures=true) is on (default false) and the environment is testing or development. The default handler writes session.userId = 1 and session.userEmail = identifier — enough for simple apps. If your app stores a richer session shape (e.g. session.member = { id, email, firstName, lastName }), add one line to config/settings.cfm:

config/settings.cfm
set(browserLoginAsHandler = "AuthFixture##loginAs");

The framework dispatches /_browser/login-as to that controller##action instead of the built-in one. Your controller is a normal Wheels controller with full access to params, session, model(), and inject(). Env-gating is handled by wheels.middleware.BrowserTestFixtureGuard on the /_browser scope — your handler does not need to re-implement the guard. Remove the setting or set it to an empty string to fall back to the default. No real login form, no password hashing, no redirect handshake. Use loginAs when the spec’s intent is “what does an authenticated user see”, not “what does the login form do”. All /_browser/* fixture routes appear under the Internal tab in the Routes UI (/wheels/routes), not Application — they are framework-owned and do not correspond to files in your app.

tests/specs/browser/AuthenticatedSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
function run() {
browserDescribe("Authenticated dashboard", () => {
it("shows the user's posts", () => {
this.browser
.loginAs("alice@example.com")
.visitRoute(route="posts")
.assertUrlContains("/posts")
.assertSee("alice@example.com");
});
});
}
}

logout() clears cookies and navigates to the fixture home page. For testing the real logout UI (click button, see redirect), use click() or press() against the live markup instead.

acceptDialog, dismissDialog, and dialogMessage handle native JavaScript dialogs (alert, confirm, prompt). They use createDynamicProxy, which is Lucee-specific — on Adobe CF and BoxLang the methods throw a clear error. Register intent before the interaction that triggers the dialog.

tests/specs/browser/DialogSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
function run() {
browserDescribe("Delete confirmation", () => {
it("accepts the native confirm dialog", () => {
this.browser
.visitRoute(route="post", key=42)
.acceptDialog()
.click("button.delete")
.assertUrlContains("/posts");
});
});
}
}

dialogMessage() is a terminal — it returns the text from the last handled dialog, useful for asserting on confirm("Really delete?") copy.

  • resize(width, height) — set exact viewport dimensions.
  • resizeToMobile() — 375 × 667 (iPhone SE).
  • resizeToTablet() — 768 × 1024 (iPad portrait).
  • resizeToDesktop() — 1440 × 900.

You can also set this.browserViewport = "mobile" on the spec class to start every it at mobile size.

  • script(js) — evaluate a JavaScript expression in the page context and return the result. Arrow-function syntax required: script("() => document.title").
  • pause(milliseconds) — sleep. Prints a warning to System.err so stray pauses don’t sneak past review. Set BROWSER_TEST_PAUSE_WARNING=off to silence. Use for hand-inspecting state during development, never for synchronizing with the page — use waitFor/waitForText/waitForUrl instead.
  • assertSee(text) / assertDontSee(text) — substring match on the full page HTML.
  • assertSeeIn(selector, text) — substring match scoped to one element.
  • assertVisible(selector) / assertMissing(selector) — visibility check (element exists and is rendered vs. does not exist or is hidden).
  • assertPresent(selector) / assertNotPresent(selector) — DOM presence check (element exists in DOM regardless of visibility).
  • assertUrlIs(expected) — exact match. If expected starts with /, compares path only (strips protocol + host). Otherwise full URL.
  • assertUrlContains(substring) — forgiving substring match.
  • assertTitleContains(text) — substring match on <title>.
  • assertQueryStringHas(key, value?) — key must exist; value is optional.
  • assertQueryStringMissing(key) — key must not exist.
  • assertRouteIs(route, key?, params?) — compare current path against a named route.
  • assertInputValue(selector, value) — input’s current value.
  • assertChecked(selector) — checkbox or radio is checked.
  • assertHasClass(selector, class) — element has the given class (case-insensitive list match).

These return a value instead of this, so they end the chain.

  • currentUrl() — full URL of the current page.
  • title()<title> contents.
  • pageSource() — full HTML of the rendered page.
  • text(selector)textContent of the first matching element.
  • value(selector)inputValue of an input/textarea/select.
  • screenshot(path, fullPage?, quality?) — write a PNG to path. Returns this, so it also chains — treat it as a side effect.
  • cookie(name) — struct form described under “cookies and state”.

Wheels form helpers emit two selector hooks on every field — a dashed id="post-title" for DOM labels and CSS, and an underscored data-auto-id="post_title" for test selectors. The underscored form is stable against rename refactors; use it in browser specs.

tests/specs/browser/NewPostSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
function run() {
browserDescribe("Create a post", () => {
it("submits the new-post form", () => {
this.browser
.loginAs("alice@example.com")
.visitRoute(route="newPost")
.fill("[data-auto-id=""post_title""]", "My Title")
.fill("[data-auto-id=""post_body""]", "Some body text")
.click("button[type=submit]")
.assertUrlContains("/posts/")
.assertSee("My Title");
});
});
}
}

See View & Form Tests for the same data-auto-id pattern applied to non-browser tests, and Forms and Form Helpers for how the attribute is generated.

Most of the DSL is engine-agnostic — Playwright drives a real Chromium regardless of which CFML engine is running your app. The exceptions:

  • DialogsacceptDialog, dismissDialog, dialogMessage use createDynamicProxy to register a listener on Playwright’s Java-side Dialog interface. That API is Lucee-only. On Adobe CF and BoxLang, calls throw Wheels.BrowserDialogNotSupported. Wrap dialog-driven specs in an engine check (if (server.coldfusion.productname != "Lucee") return;) if you run the suite across engines.
  • loginAs — relies on the /_browser/login-as fixture route. Nothing restores that route automatically: if another spec clears or replaces the route table (a $clearRoutes()-style spec), loginAs and every /_browser/* request will 404 until the routes reload. Make any spec that manipulates the route table restore it before browser specs run.

See the cross-engine notes in CLAUDE.md for the full list of Lucee/Adobe/BoxLang divergences.

When a spec fails, the aroundEach hook captures a screenshot and HTML dump to tests/_output/browser/<specName>-<timestamp>.{png,html}. Open the PNG to see the rendered state at the moment of failure. Beyond that:

  • this.browser.pause(5000) sleeps for five seconds so you can inspect the browser manually — only useful when Playwright launches in headed mode.
  • this.browser.screenshot("/tmp/midway.png") captures a snapshot at any point in the chain.
  • pageSource(), text(selector), and currentUrl() return inspectable strings — dump them to the test output to see what the page looked like.
  • getPage(), getContext(), and getLauncher() drop you down to the raw Playwright objects for anything the DSL doesn’t cover.

The CI workflow sets WHEELS_CI=true, which BrowserTest.beforeAll interprets as an opt-out from running browser specs (they’re skipped via this.browserTestSkipped = true). To run browser specs in CI, also set WHEELS_BROWSER_CI_ENABLE=true and ensure Playwright is installed on the runner. CI caches the Playwright JARs and Chromium via the browser-manifest.json hash; first runs pay the download cost once.

Set WHEELS_BROWSER_TEST_BASE_URL, set this.baseUrl in the component pseudo-constructor, or rely on CGI auto-detect so visit("/path") resolves to the right origin. The env var is the standard CI escape hatch; this.baseUrl takes priority and lets individual specs target a different port without touching the environment. Set it in the pseudo-constructor — outside any function — so it is in place before super.beforeAll() runs $resolveBaseUrl() and caches the value:

component extends="wheels.wheelstest.BrowserTest" {
this.baseUrl = "http://staging:60050"; // pseudo-constructor — set before super.beforeAll()
function run() {
// specs ...
}
}

For local work against a non-8080 server (Titan on 60050, wheels new scaffolds on 60080), CGI auto-detect picks up the correct host and port automatically. See CI Integration for the full workflow.

Browser tests are the slowest layer of the pyramid — every spec boots Chromium, loads the page, and drives real user interactions. Reach for them when the behavior requires JavaScript, Turbo, or real-browser event ordering. For everything else, prefer a faster layer: