Skip to content

Commit 5e6adca

Browse files
bpamiriclaude
andauthored
docs(web/guides): correct 20 audited testing-guide claims (batch 2) (#3099)
Fixes the docs-wrong findings from the p1-15-testing behavioral audit: runner directory/testBundles filtering, populate opt-out default, weekly compat matrix, --ci/--reporter behavior, isPersisted(), assertSee case-insensitivity, &##x27; entity, exact Content-Type match, populate.cfm queryExecute rewrite, table() setter, browserDescribe auto-skip, dialog error type, dashed form ids, fixture-route warning, core-mode default, compose DB services, Oracle soft-fail, JAVA_HOME qualifier. Cites ##3025 and ##3083 for open runner issues. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f864ee8 commit 5e6adca

8 files changed

Lines changed: 62 additions & 60 deletions

File tree

web/sites/guides/src/content/docs/v4-0-0/testing/browser-tests.mdx

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ wheels browser setup
3838

3939
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.
4040

41-
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 — but every `it` in a browser spec still needs to check the flag.
41+
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.
4242

4343
## A minimal browser spec
4444

@@ -50,7 +50,6 @@ component extends="wheels.wheelstest.BrowserTest" {
5050
function run() {
5151
browserDescribe("Home page", () => {
5252
it("loads the home page", () => {
53-
if (this.browserTestSkipped) return;
5453
this.browser
5554
.visitUrl("http://localhost:8080/")
5655
.assertTitleContains("My App");
@@ -63,7 +62,7 @@ component extends="wheels.wheelstest.BrowserTest" {
6362
Two things to note in every browser spec:
6463

6564
1. **`extends="wheels.wheelstest.BrowserTest"`** — not `wheels.WheelsTest`. The browser base class adds the Playwright lifecycle hooks on top of BDD.
66-
2. **`if (this.browserTestSkipped) return;`** — the first line of every `it`. When Playwright JARs aren't installed, or when the CI gate (`WHEELS_CI=true` without `WHEELS_BROWSER_CI_ENABLE=true`) is active, `beforeAll` sets the flag. Without the guard, the spec would crash on the first `this.browser` call instead of staying green.
65+
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.
6766

6867
## `browserDescribe` vs `describe`
6968

@@ -74,13 +73,11 @@ component extends="wheels.wheelstest.BrowserTest" {
7473
function run() {
7574
browserDescribe("Isolated per-it state", () => {
7675
it("starts with no cookies", () => {
77-
if (this.browserTestSkipped) return;
7876
this.browser.visitUrl("http://localhost:8080/")
7977
.clearCookies()
8078
.assertSee("Sign in");
8179
});
8280
it("also starts with no cookies", () => {
83-
if (this.browserTestSkipped) return;
8481
// Fresh context; the previous test's state is gone.
8582
this.browser.visitUrl("http://localhost:8080/")
8683
.assertSee("Sign in");
@@ -106,7 +103,6 @@ component extends="wheels.wheelstest.BrowserTest" {
106103
function run() {
107104
browserDescribe("Navigation", () => {
108105
it("follows a route and comes back", () => {
109-
if (this.browserTestSkipped) return;
110106
this.browser
111107
.visitRoute(route="posts")
112108
.click("a.post-link")
@@ -152,7 +148,6 @@ component extends="wheels.wheelstest.BrowserTest" {
152148
function run() {
153149
browserDescribe("Scoped form", () => {
154150
it("fills the signin form, not the signup form", () => {
155-
if (this.browserTestSkipped) return;
156151
this.browser
157152
.visitUrl("http://localhost:8080/sign-in")
158153
.within("form##signin", (scoped) => {
@@ -176,7 +171,7 @@ component extends="wheels.wheelstest.BrowserTest" {
176171

177172
## The DSL — auth fixtures
178173

179-
`loginAs(identifier)` takes a single string (email, username, or whatever your app uses to identify a user) and navigates to `/_browser/login-as?identifier=...`. That route is mounted automatically in test mode. 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`:
174+
`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`:
180175

181176
```cfm title="config/settings.cfm"
182177
set(browserLoginAsHandler = "AuthFixture##loginAs");
@@ -189,7 +184,6 @@ component extends="wheels.wheelstest.BrowserTest" {
189184
function run() {
190185
browserDescribe("Authenticated dashboard", () => {
191186
it("shows the user's posts", () => {
192-
if (this.browserTestSkipped) return;
193187
this.browser
194188
.loginAs("alice@example.com")
195189
.visitRoute(route="posts")
@@ -212,7 +206,6 @@ component extends="wheels.wheelstest.BrowserTest" {
212206
function run() {
213207
browserDescribe("Delete confirmation", () => {
214208
it("accepts the native confirm dialog", () => {
215-
if (this.browserTestSkipped) return;
216209
this.browser
217210
.visitRoute(route="post", key=42)
218211
.acceptDialog()
@@ -276,14 +269,13 @@ These return a value instead of `this`, so they end the chain.
276269

277270
## Targeting form fields with `data-auto-id`
278271

279-
Wheels form helpers emit two selector hooks on every field — a camelCase `id="postTitle"` for browser URL hashes, and an underscored `data-auto-id="post_title"` for test selectors. The underscored form is stable against rename refactors; use it in browser specs.
272+
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.
280273

281274
```cfm {test:compile} title="tests/specs/browser/NewPostSpec.cfc"
282275
component extends="wheels.wheelstest.BrowserTest" {
283276
function run() {
284277
browserDescribe("Create a post", () => {
285278
it("submits the new-post form", () => {
286-
if (this.browserTestSkipped) return;
287279
this.browser
288280
.loginAs("alice@example.com")
289281
.visitRoute(route="newPost")
@@ -304,10 +296,10 @@ See [View & Form Tests](/v4-0-0/testing/view-and-form-tests/) for the same `data
304296

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

307-
- **Dialogs**`acceptDialog`, `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.DialogSupportMissing`. Wrap dialog-driven specs in an engine check (`if (server.coldfusion.productname != "Lucee") return;`) if you run the suite across engines.
308-
- **`loginAs`** — relies on the `/_browser/login-as` fixture route, which is mounted automatically in test mode from `vendor/wheels/tests/routes.cfm`. If your app clears or replaces the route table in another spec, `BrowserTest.beforeAll` re-includes the fixture routes so browser specs stay self-contained.
299+
- **Dialogs**`acceptDialog`, `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.
300+
- **`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.
309301

310-
See the cross-engine notes in [`CLAUDE.md`](https://github.com/cfwheels/cfwheels/blob/develop/CLAUDE.md) for the full list of Lucee/Adobe/BoxLang divergences.
302+
See the cross-engine notes in [`CLAUDE.md`](https://github.com/wheels-dev/wheels/blob/develop/CLAUDE.md) for the full list of Lucee/Adobe/BoxLang divergences.
311303

312304
## Debugging failing specs
313305

web/sites/guides/src/content/docs/v4-0-0/testing/ci-integration.mdx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747

4848
- name: Install LuCLI
4949
run: |
50-
curl -sL "https://github.com/cybersonic/LuCLI/releases/download/v0.3.3/lucli-0.3.3-linux" \
50+
curl -sL "https://github.com/cybersonic/LuCLI/releases/download/v0.3.7/lucli-0.3.7-linux" \
5151
-o /usr/local/bin/lucli
5252
chmod +x /usr/local/bin/lucli
5353
@@ -71,16 +71,17 @@ If you want the raw CLI instead of the bash wrapper, the equivalent is `wheels s
7171

7272
## Reporter output for CI
7373

74-
The `wheels test` command accepts a `--reporter=<name>` flag, but understanding what it does today avoids a surprise: the LuCLI implementation always requests JSON from the test endpoint and then prints a human-readable summary on success or failure details on failure. The flag is parsed but does not currently select between output formats — you get the same output regardless of `--reporter` value.
74+
The `wheels test` command accepts a `--reporter=<name>` flag. The CLI always requests JSON from the test endpoint, then formats the result for the reporter you picked:
7575

7676
| Flag | Behaviour today |
7777
| --- | --- |
7878
| `--reporter=simple` (default) | Human-readable summary: `N passed (Xs)` on green, plus failure details on red |
79-
| `--reporter=<anything else>` | Accepted, currently no-op — same human-readable output |
80-
| `--ci` | Accepted, reserved for CI-specific behaviour — currently a no-op in the output path |
79+
| `--reporter=json` | Emits the raw JSON result document — pipe it to `jq` or a post-processor |
80+
| `--reporter=tap` | Emits TAP version 13 (`1..N`, `ok` / `not ok` lines) for TAP-consuming CI tooling |
81+
| `--ci` | Accepted for forward-compatibility — currently changes nothing; every run already exits non-zero on failure |
8182
| `--verbose` / `-v` | Adds the full bundle/suite/spec tree to the output |
8283

83-
For machine-readable results, call the test runner URL directly and post-process the JSON. That is exactly what `tools/ci/run-tests.sh` does in this repo: it `curl`s `/wheels/core/tests?db=sqlite&format=json`, parses the totals in Python, and emits a JUnit XML file that `actions/upload-artifact` ingests for the GitHub summary.
84+
For machine-readable results you can also call the test runner URL directly and post-process the JSON. That is exactly what `tools/ci/run-tests.sh` does in this repo: it `curl`s `/wheels/core/tests?db=sqlite&format=json`, parses the totals in Python, and emits a JUnit XML file that `actions/upload-artifact` ingests for the GitHub summary.
8485

8586
```bash title="tools/ci/run-tests.sh (excerpt)"
8687
curl -s -o "$RESULT_FILE" --max-time 600 \
@@ -102,7 +103,7 @@ Browser specs are expensive to run in CI — they need the Playwright JARs (~370
102103
- **`WHEELS_CI=true`** — mark the environment as CI
103104
- **`WHEELS_BROWSER_CI_ENABLE=true`** (or `1` or `yes`) — opt browser specs in
104105

105-
If `WHEELS_CI` is set and `WHEELS_BROWSER_CI_ENABLE` is not one of `true,1,yes`, `BrowserTest.cfc` sets `this.browserTestSkipped = true` in `beforeAll`. Every `it` block in a browser spec that begins with `if (this.browserTestSkipped) return;` then exits without running. The suite stays green; the browser tests simply don't count.
106+
If `WHEELS_CI` is set and `WHEELS_BROWSER_CI_ENABLE` is not one of `true,1,yes`, `BrowserTest.cfc` sets `this.browserTestSkipped = true` in `beforeAll`, and `browserDescribe`'s `aroundEach` skips every `it` automatically. The suite stays green; the browser tests simply don't count.
106107

107108
```yaml title=".github/workflows/tests.yml (fragment)"
108109
env:

web/sites/guides/src/content/docs/v4-0-0/testing/fixtures-and-test-data.mdx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ The trigger conditions live in `vendor/wheels/tests/runner.cfm`. The runner read
3131

3232
This is a significant departure from Rails or Laravel, where the framework wraps each test in a transaction and rolls back. Wheels sets `application.wheels.transactionMode = "none"` in `runner.cfm`, so writes during a test run persist across specs. The two mechanisms you have for isolation are: (1) make `populate.cfm` idempotent so re-running it always resets the world, and (2) wrap destructive specs in a manual `transaction { ... }` block that rolls back.
3333

34+
<Aside type="caution">
35+
The runner applies these settings (and path swaps like `modelPath`) to the **live** `application.wheels` of the server it runs against — they can leak into normal requests served during and after a test run. Track [#3025](https://github.com/wheels-dev/wheels/issues/3025) for the isolation work; until then, prefer a dedicated test server over running the suite against the instance you're browsing.
36+
</Aside>
37+
3438
<Aside type="caution">
3539
`populate.cfm` does not run between specs. A `create()` in `specs/models/PostSpec.cfc` leaves a row visible to `specs/models/CommentSpec.cfc`. Design specs to tolerate shared state, or roll back writes explicitly.
3640
</Aside>
@@ -44,13 +48,17 @@ The canonical pattern is DROP + CREATE + seed, in that order, with the drops in
4448
// Runs once per test run. Triggered by runner.cfm whenever url.populate is
4549
// truthy (default) or when the sentinel table is missing.
4650
51+
function runSql(required string sql) {
52+
queryExecute(arguments.sql, {}, {datasource: application.wheels.dataSourceName});
53+
}
54+
4755
// Drop existing tables (reverse dependency order)
48-
try { application.wo.execute("DROP TABLE comments"); } catch (any e) {}
49-
try { application.wo.execute("DROP TABLE posts"); } catch (any e) {}
50-
try { application.wo.execute("DROP TABLE users"); } catch (any e) {}
56+
try { runSql("DROP TABLE comments"); } catch (any e) {}
57+
try { runSql("DROP TABLE posts"); } catch (any e) {}
58+
try { runSql("DROP TABLE users"); } catch (any e) {}
5159
5260
// Create tables
53-
application.wo.execute("
61+
runSql("
5462
CREATE TABLE users (
5563
id INTEGER PRIMARY KEY AUTOINCREMENT,
5664
email VARCHAR(100) UNIQUE NOT NULL,
@@ -60,7 +68,7 @@ application.wo.execute("
6068
)
6169
");
6270
63-
application.wo.execute("
71+
runSql("
6472
CREATE TABLE posts (
6573
id INTEGER PRIMARY KEY AUTOINCREMENT,
6674
userId INTEGER NOT NULL,
@@ -72,7 +80,7 @@ application.wo.execute("
7280
)
7381
");
7482
75-
application.wo.execute("
83+
runSql("
7684
CREATE TABLE comments (
7785
id INTEGER PRIMARY KEY AUTOINCREMENT,
7886
postId INTEGER NOT NULL,
@@ -84,20 +92,20 @@ application.wo.execute("
8492
");
8593
8694
// Seed known fixtures
87-
application.wo.execute("
95+
runSql("
8896
INSERT INTO users (email, passwordHash, createdAt, updatedAt)
8997
VALUES ('alice@example.com', 'hash', NOW(), NOW()),
9098
('bob@example.com', 'hash', NOW(), NOW())
9199
");
92100
93-
application.wo.execute("
101+
runSql("
94102
INSERT INTO posts (userId, title, body, status, createdAt, updatedAt)
95103
VALUES (1, 'Welcome', 'First post', 'published', NOW(), NOW())
96104
");
97105
</cfscript>
98106
```
99107

100-
Two things to notice. First, every call goes through `application.wo.execute(...)` rather than a bare `execute(...)`. That's the scope gotcha (covered below) — Wheels' internal functions are not available as globals inside a plain `.cfm` include. Second, the schema uses SQLite-flavoured syntax (`AUTOINCREMENT`, `TEXT`) because SQLite is the inner-loop reference platform for Wheels 4.0 tests.
108+
Two things to notice. First, every call goes through native `queryExecute(...)` rather than a bare `execute(...)` — there is no global `execute()` in a plain `.cfm` include, and none on `application.wo` either; `execute()` exists only inside migration CFCs. That's the scope gotcha (covered below). Second, the schema uses SQLite-flavoured syntax (`AUTOINCREMENT`, `TEXT`) because SQLite is the inner-loop reference platform for Wheels 4.0 tests.
101109

102110
## Per-spec isolation — the manual pattern
103111

@@ -137,7 +145,7 @@ Sometimes you want a model that exists only during the test run — a stripped-d
137145
```cfm {test:compile} title="tests/_assets/models/TestPost.cfc"
138146
component extends="Model" {
139147
function config() {
140-
tableName("test_posts");
148+
table("test_posts");
141149
setPrimaryKey("id");
142150
}
143151
}
@@ -185,10 +193,10 @@ Factories trade a few lines of setup for a shorter, more readable spec body. Whe
185193

186194
## The scope gotcha
187195

188-
Wheels' internal functions (`model()`, `$dbinfo`, `execute()`, and friends) are not available as bare globals inside plain `.cfm` files that are included from CFCs like `TestRunner.cfc`. If you write `execute("CREATE TABLE ...")` in `populate.cfm`, you'll get a "variable EXECUTE is undefined" error.
196+
Wheels' internal functions (`model()`, `$dbinfo`, and friends) are not available as bare globals inside plain `.cfm` files that are included from CFCs like `TestRunner.cfc`. If you write `execute("CREATE TABLE ...")` in `populate.cfm`, you'll get a "No matching function [EXECUTE] found" error (Adobe words it as "variable EXECUTE is undefined").
189197

190198
<Aside type="caution">
191-
Call framework internals via `application.wo.` (the scope where Wheels stores its callable methods) or use native CFML tags. `application.wo.execute("CREATE TABLE ...")` works; so does `<cfdbinfo>`, `<cfquery>`, and `<cfdump>`. Same rule for `application.wo.model("Post")` if you need to seed through the ORM instead of raw SQL.
199+
Call framework internals via `application.wo.` (the scope where Wheels stores its callable methods) or use native CFML. `application.wo.model("Post")` works if you need to seed through the ORM. For raw SQL there is no framework helper at all here — `execute()` exists only on migration CFCs (`wheels.migrator.Migration`), not on `application.wo` — so use native `queryExecute(...)` with an explicit `datasource`, or `<cfquery>`/`<cfdbinfo>`.
192200
</Aside>
193201

194202
This is the most common first-time-authoring bug in `populate.cfm`. If your tests die at boot with "function not defined" and the stack trace points at your populate, it's this.

0 commit comments

Comments
 (0)