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
BrowserTestbase class and thebrowserDescribewrapper - The full
BrowserClientDSL — navigation, interaction, assertions, terminals - How to bypass the login form with the
loginAsfixture - Cross-engine caveats (dialogs are Lucee-only)
Install Playwright
Section titled “Install Playwright”Browser tests require Playwright’s Java client plus Chromium. The wheels browser setup command downloads both (~370MB one-time) and verifies SHA-256 hashes.
wheels --versionThen, inside your project:
wheels browser setupThe 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.
A minimal browser spec
Section titled “A minimal browser spec”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:
extends="wheels.wheelstest.BrowserTest"— notwheels.WheelsTest. The browser base class adds the Playwright lifecycle hooks on top of BDD.- Use
browserDescribe(), not plaindescribe(). When Playwright JARs aren’t installed, or when the CI gate (WHEELS_CI=truewithoutWHEELS_BROWSER_CI_ENABLE=true) is active,beforeAllsetsthis.browserTestSkipped— andbrowserDescribe’saroundEachskips eachitautomatically. If you put browseritblocks inside a plaindescribe()instead, you must guard them yourself withif (this.browserTestSkipped) return;or they crash on the firstthis.browsercall.
browserDescribe vs describe
Section titled “browserDescribe vs describe”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.
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.
The DSL — navigation
Section titled “The DSL — navigation”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 propertywheels.browserTest.baseUrl→WHEELS_BROWSER_TEST_BASE_URLenv var → CGI auto-detect →http://localhost:8080default. Path must start with/.visitUrl(url)— navigate to an absolute URL. Use fordata:,file:, or cross-origin tests.visitRoute(route, key, params)— resolve a named route viaURLFor()and navigate there. Requires the Wheels app to be booted.back()/forward()/refresh()— drive the browser history.
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"); }); }); }}The DSL — interaction
Section titled “The DSL — interaction”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 (fireschangeat 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.
The DSL — keyboard
Section titled “The DSL — keyboard”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.
The DSL — waiting
Section titled “The DSL — waiting”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.
The DSL — scoping
Section titled “The DSL — scoping”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).
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"); }); }); }}The DSL — cookies and state
Section titled “The DSL — cookies and state”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 withname,value,domain,path,expires,httpOnly,secure. ThrowsBrowserAssertionFailedif the cookie doesn’t exist.clearCookies()— wipe all cookies.
The DSL — auth fixtures
Section titled “The DSL — auth fixtures”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:
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.
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.
The DSL — dialogs (Lucee-only)
Section titled “The DSL — dialogs (Lucee-only)”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.
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.
The DSL — viewport
Section titled “The DSL — viewport”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.
The DSL — script and pause
Section titled “The DSL — script and pause”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 toSystem.errso stray pauses don’t sneak past review. SetBROWSER_TEST_PAUSE_WARNING=offto silence. Use for hand-inspecting state during development, never for synchronizing with the page — usewaitFor/waitForText/waitForUrlinstead.
Assertions — text, visibility, presence
Section titled “Assertions — text, visibility, presence”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).
Assertions — URL, title, query
Section titled “Assertions — URL, title, query”assertUrlIs(expected)— exact match. Ifexpectedstarts 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.
Assertions — form state
Section titled “Assertions — form state”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).
Terminals
Section titled “Terminals”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)—textContentof the first matching element.value(selector)—inputValueof an input/textarea/select.screenshot(path, fullPage?, quality?)— write a PNG topath. Returnsthis, so it also chains — treat it as a side effect.cookie(name)— struct form described under “cookies and state”.
Targeting form fields with data-auto-id
Section titled “Targeting form fields with data-auto-id”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.
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.
Cross-engine caveats
Section titled “Cross-engine caveats”Most of the DSL is engine-agnostic — Playwright drives a real Chromium regardless of which CFML engine is running your app. The exceptions:
- Dialogs —
acceptDialog,dismissDialog,dialogMessageusecreateDynamicProxyto register a listener on Playwright’s Java-sideDialoginterface. That API is Lucee-only. On Adobe CF and BoxLang, calls throwWheels.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-asfixture route. Nothing restores that route automatically: if another spec clears or replaces the route table (a$clearRoutes()-style spec),loginAsand 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.
Debugging failing specs
Section titled “Debugging failing specs”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), andcurrentUrl()return inspectable strings — dump them to the test output to see what the page looked like.getPage(),getContext(), andgetLauncher()drop you down to the raw Playwright objects for anything the DSL doesn’t cover.
CI integration
Section titled “CI integration”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.
When to skip browser tests
Section titled “When to skip browser tests”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:
- Server-rendered HTML, redirects, status codes → Controller Tests
- Rendered markup without JS → View & Form Tests
- Multi-controller flows without UI assertions → Functional Tests
- Model behavior → Model Tests