Skip to content

Commit 87bc34b

Browse files
authored
Merge pull request #1923 from wheels-dev/peter/testing-docs-gotchas
docs: add common testing gotchas
2 parents 6978d0b + 7366008 commit 87bc34b

File tree

3 files changed

+303
-3
lines changed

3 files changed

+303
-3
lines changed

.ai/wheels/testing/unit-testing.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Unit & Integration Testing in Wheels
2+
3+
## Two Test Frameworks
4+
5+
Wheels has two test frameworks. **All new tests must use TestBox.**
6+
7+
| | TestBox (current) | RocketUnit (legacy) |
8+
|---|---|---|
9+
| **Syntax** | `describe`/`it`/`expect` (BDD) | `test_methodName()` + `assert()` |
10+
| **Base class** | `wheels.WheelsTest` | `wheels.tests.Test` |
11+
| **Location** | `tests/specs/` | `vendor/wheels/tests/` |
12+
| **Runner URL** | `/wheels/app/tests` | `/wheels/tests/core` |
13+
| **Status** | Active, all new tests | Legacy, backwards-compat only |
14+
15+
## TestBox Test Structure
16+
17+
### File Layout
18+
19+
```
20+
tests/
21+
_assets/
22+
models/ <- Test-only model CFCs (not app models)
23+
Model.cfc <- Base model (extends wheels.Model)
24+
Author.cfc <- Test model with table() override
25+
Post.cfc
26+
specs/
27+
models/ <- Model specs
28+
BatchProcessingSpec.cfc
29+
QueryBuilderSpec.cfc
30+
controllers/ <- Controller specs
31+
functional/ <- End-to-end specs
32+
populate.cfm <- Creates/seeds test tables (runs before every test suite)
33+
runner.cfm <- TestBox runner (web entry point)
34+
```
35+
36+
### Writing a Spec
37+
38+
```cfm
39+
component extends="wheels.WheelsTest" {
40+
function run() {
41+
describe("Feature Name", () => {
42+
43+
it("does something specific", () => {
44+
var result = model("author").findAll();
45+
expect(result.recordcount).toBeGT(0);
46+
});
47+
48+
it("returns a model object", () => {
49+
var obj = model("author").findOne(order="id");
50+
expect(IsObject(obj)).toBeTrue();
51+
expect(obj.firstName).toBe("Per");
52+
});
53+
54+
});
55+
}
56+
}
57+
```
58+
59+
### Key Points
60+
61+
- **Extend `wheels.WheelsTest`** — this injects all `application.wo` methods (like `model()`) into the test scope automatically.
62+
- **Use `function run()`** — TestBox calls this to discover specs. Not `init()`, not `config()`.
63+
- **Arrow functions work**`() => {}` is fine for `describe`/`it`/`beforeEach`.
64+
65+
## Test Models
66+
67+
Test models live in `tests/_assets/models/` and extend the local `Model.cfc` (which extends `wheels.Model`). They use `table()` to map to test tables created by `populate.cfm`.
68+
69+
```cfm
70+
// tests/_assets/models/Author.cfc
71+
component extends="Model" {
72+
function config() {
73+
table("c_o_r_e_authors");
74+
hasMany("posts");
75+
}
76+
}
77+
```
78+
79+
The test environment sets `modelPath` to `tests/_assets/models/` so `model("author")` resolves to your test model, not an app model.
80+
81+
## Test Data (populate.cfm)
82+
83+
`tests/populate.cfm` runs before every test suite invocation. It creates tables and seeds data.
84+
85+
**Always use DROP + CREATE, never IF NOT EXISTS:**
86+
87+
```cfm
88+
<!--- DROP first — IF NOT EXISTS misses schema changes --->
89+
<cftry>
90+
<cfquery datasource="#application.wheels.dataSourceName#">
91+
DROP TABLE IF EXISTS c_o_r_e_posts
92+
</cfquery>
93+
<cfcatch></cfcatch>
94+
</cftry>
95+
96+
<!--- Then CREATE --->
97+
<cfquery datasource="#application.wheels.dataSourceName#">
98+
CREATE TABLE c_o_r_e_posts (
99+
id #local.identityColumnType#,
100+
title varchar(250) NOT NULL,
101+
...
102+
PRIMARY KEY(id)
103+
) #local.storageEngine#
104+
</cfquery>
105+
```
106+
107+
**Why not IF NOT EXISTS?** If you add a column (like `status`) to a table that already exists from a previous test run, IF NOT EXISTS skips the CREATE and the column is missing. DROP + CREATE guarantees a clean schema every time.
108+
109+
## Running Tests
110+
111+
### Via URL (most reliable)
112+
113+
```
114+
# All specs in a directory
115+
/wheels/app/tests?format=json&directory=tests.specs.models
116+
117+
# Single spec bundle
118+
/wheels/app/tests?format=json&bundles=tests.specs.models.BatchProcessingSpec
119+
120+
# HTML output (for browser)
121+
/wheels/app/tests?format=html&directory=tests.specs.models
122+
123+
# Force model cache reload (needed after adding new model CFCs)
124+
/wheels/app/tests?format=json&directory=tests.specs.models&reload=true
125+
```
126+
127+
### Via curl + node (for CLI parsing)
128+
129+
```bash
130+
curl -sL "http://localhost:60006/wheels/app/tests?format=json&directory=tests.specs.models&reload=true" \
131+
> /tmp/testbox_results.json && node -e "
132+
const j = JSON.parse(require('fs').readFileSync('/tmp/testbox_results.json', 'utf8'));
133+
console.log('Passed:', j.totalPass, '| Failed:', j.totalFail, '| Errors:', j.totalError);
134+
for (const b of j.bundleStats) {
135+
console.log('\n' + b.name + ' (' + b.totalPass + '/' + b.totalSpecs + ')');
136+
function printSuite(s, indent) {
137+
for (const sp of (s.specStats || [])) {
138+
if (sp.status !== 'Passed') console.log(indent + '[FAIL] ' + sp.name + ': ' + sp.failMessage);
139+
}
140+
for (const ns of (s.suiteStats || [])) printSuite(ns, indent + ' ');
141+
}
142+
for (const s of b.suiteStats) printSuite(s, ' ');
143+
}
144+
"
145+
```
146+
147+
**Why node instead of jq?** The Wheels TestBox JSON response contains unquoted `true`/`false` booleans that break strict JSON parsers. Node's `JSON.parse` handles them.
148+
149+
## Common Gotchas
150+
151+
### 1. CFML Closure Scoping
152+
153+
Closures in CFML have their own `local` scope. You **cannot** read/write outer `local` variables from inside a closure.
154+
155+
```cfm
156+
// WRONG — `local.count` inside the closure is a DIFFERENT variable
157+
var count = 0;
158+
model("author").findEach(callback = function(author) {
159+
count++; // This modifies the closure's local.count, not the outer one
160+
});
161+
expect(count).toBe(10); // FAILS — count is still 0
162+
163+
// RIGHT — use a shared struct (structs are passed by reference)
164+
var result = {count: 0};
165+
model("author").findEach(callback = function(author) {
166+
result.count++; // Modifies the shared struct
167+
});
168+
expect(result.count).toBe(10); // PASSES
169+
```
170+
171+
### 2. Model Cache After Adding New CFCs
172+
173+
After adding a new model CFC to `tests/_assets/models/`, the first test run may fail with errors like `table 'authorscopeds' not found` — Wheels is using default table name conventions because it hasn't loaded your `config()` yet.
174+
175+
**Fix:** Add `&reload=true` to the test runner URL to clear the model cache.
176+
177+
### 3. Table Naming in Test Models
178+
179+
Always call `table()` in your test model's `config()` to map to the test table name. Without it, Wheels pluralizes the model name (e.g., `AuthorScoped` -> `authorscopeds`).
180+
181+
```cfm
182+
component extends="Model" {
183+
function config() {
184+
table("c_o_r_e_authors"); // Explicit table name
185+
}
186+
}
187+
```
188+
189+
### 4. Drop Order for Foreign Keys
190+
191+
Drop child tables before parent tables in `populate.cfm`:
192+
193+
```cfm
194+
DROP TABLE IF EXISTS c_o_r_e_posts <!--- child (has authorid FK) --->
195+
DROP TABLE IF EXISTS c_o_r_e_authors <!--- parent --->
196+
```
197+
198+
### 5. Pre-existing Test Failures
199+
200+
The `vendor/wheels/tests/` RocketUnit suite has some pre-existing failures (e.g., in `model.errors`). Don't chase these — they're known issues in the legacy suite. Focus on making your TestBox specs green.

CLAUDE.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,14 @@ Helpers: `linkTo(route="user", key=user.id, text="View")`, `urlFor(route="users"
276276

277277
## Testing Quick Reference
278278

279+
**All new tests use TestBox BDD syntax.** RocketUnit (`test_` prefix, `assert()`) is legacy only — never use it for new tests.
280+
279281
```cfm
282+
// tests/specs/models/MyFeatureSpec.cfc
280283
component extends="wheels.WheelsTest" {
281284
function run() {
282-
describe("User", function() {
283-
it("validates presence of name", function() {
285+
describe("My Feature", () => {
286+
it("validates presence of name", () => {
284287
var user = model("User").new();
285288
expect(user.valid()).toBeFalse();
286289
});
@@ -289,7 +292,13 @@ component extends="wheels.WheelsTest" {
289292
}
290293
```
291294

292-
Tests live in `tests/models/`, `tests/controllers/`, `tests/integration/`. Run with MCP `wheels_test()` or CLI `wheels test run`.
295+
- **Specs**: `tests/specs/models/`, `tests/specs/controllers/`, `tests/specs/functional/`
296+
- **Test models**: `tests/_assets/models/` (use `table()` to map to test tables)
297+
- **Test data**: `tests/populate.cfm` (DROP + CREATE tables, seed data)
298+
- **Runner URL**: `/wheels/app/tests?format=json&directory=tests.specs.models`
299+
- **Force reload**: append `&reload=true` after adding new model CFCs
300+
- **Closure gotcha**: CFML closures can't access outer `local` vars — use shared structs (`var result = {count: 0}`)
301+
- Run with MCP `wheels_test()` or CLI `wheels test run`
293302

294303
## Background Jobs Quick Reference
295304

@@ -356,6 +365,7 @@ Deeper documentation lives in `.ai/` — Claude will search it automatically whe
356365
- `.ai/wheels/controllers/` — filters, rendering, security
357366
- `.ai/wheels/views/` — layouts, partials, form helpers, link helpers
358367
- `.ai/wheels/database/` — migration column types, queries, advanced operations
368+
- `.ai/wheels/testing/` — unit testing with TestBox, test infrastructure, common gotchas
359369
- `.ai/wheels/configuration/` — routing, environments, settings
360370

361371
## MCP Server

docs/src/working-with-wheels/testing-your-application.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,96 @@ Your test suite should provide comprehensive coverage for:
724724

725725
For detailed guidance on what to test and testing strategies, see the [TestBox Testing Code Coverage documentation](https://testbox.ortusbooks.com/v6.x/digging-deeper/introduction).
726726

727+
## Common Gotchas
728+
729+
### CFML Closure Scoping
730+
731+
Closures in CFML have their own `local` scope. You **cannot** read or write outer `local` variables from inside a closure — assignments create a new variable in the closure's scope instead.
732+
733+
```cfm
734+
// WRONG — count inside the closure is a different variable
735+
var count = 0;
736+
model("User").findEach(callback = function(user) {
737+
count++; // Modifies the closure's own local.count, not the outer one
738+
});
739+
expect(count).toBe(10); // FAILS — count is still 0
740+
741+
// RIGHT — use a shared struct (structs are passed by reference)
742+
var result = {count: 0};
743+
model("User").findEach(callback = function(user) {
744+
result.count++; // Modifies the shared struct
745+
});
746+
expect(result.count).toBe(10); // PASSES
747+
```
748+
749+
This applies to any test that passes a closure to a Wheels method (`findEach`, `findInBatches`, callbacks, etc.).
750+
751+
### Model Cache Requires Reload After Adding New Models
752+
753+
When you add a new model CFC to `tests/_assets/models/`, the first test run may fail with errors like `table 'authorscopeds' not found`. This happens because Wheels caches model metadata and hasn't loaded your new model's `config()` method yet — so it falls back to convention-based table naming.
754+
755+
**Fix:** Append `&reload=true` to the test runner URL to clear the model cache:
756+
757+
```
758+
http://localhost:8080/wheels/app/tests?format=json&directory=tests.specs.models&reload=true
759+
```
760+
761+
You only need to do this once after adding or renaming model CFCs. Subsequent runs without `&reload=true` will work fine.
762+
763+
### Use DROP + CREATE in populate.cfm, Not IF NOT EXISTS
764+
765+
When setting up test tables in `populate.cfm`, always drop and recreate tables rather than using `CREATE TABLE IF NOT EXISTS`:
766+
767+
```cfm
768+
<!--- WRONG — if the table exists from a previous run with a different schema,
769+
IF NOT EXISTS skips the CREATE and your new columns are missing --->
770+
<cfquery datasource="#application.wheels.dataSourceName#">
771+
CREATE TABLE IF NOT EXISTS my_test_table (...)
772+
</cfquery>
773+
774+
<!--- RIGHT — DROP first guarantees a clean schema every time --->
775+
<cftry>
776+
<cfquery datasource="#application.wheels.dataSourceName#">
777+
DROP TABLE IF EXISTS my_test_table
778+
</cfquery>
779+
<cfcatch></cfcatch>
780+
</cftry>
781+
<cfquery datasource="#application.wheels.dataSourceName#">
782+
CREATE TABLE my_test_table (...)
783+
</cfquery>
784+
```
785+
786+
**Why?** If you add a column to a test table (e.g., adding a `status` column for enum tests), `IF NOT EXISTS` silently skips the `CREATE` when the table already exists from a previous test run — and the new column is missing. DROP + CREATE ensures the schema matches your current test requirements.
787+
788+
{% hint style="warning" %}
789+
When dropping tables, drop child tables (with foreign keys) before parent tables to avoid constraint errors.
790+
{% endhint %}
791+
792+
### Test Model CFCs Must Override the Table Name
793+
794+
Test models in `tests/_assets/models/` should always call `table()` in their `config()` to explicitly set the table name. Without it, Wheels pluralizes the component name using conventions — which often produces unexpected results for compound names.
795+
796+
```cfm
797+
// Without table() override, Wheels would look for a table called "authorscopeds"
798+
component extends="Model" {
799+
function config() {
800+
table("c_o_r_e_authors"); // Explicit table name
801+
scope(name="active", where="active = 1");
802+
}
803+
}
804+
```
805+
806+
### TestBox JSON Output Contains Non-Standard Booleans
807+
808+
When parsing TestBox JSON results programmatically (e.g., in CI/CD scripts), be aware that the Wheels TestBox runner may produce JSON with unquoted `true`/`false` boolean values, which can cause strict JSON parsers to fail.
809+
810+
**Workaround:** Use Node.js `JSON.parse()` instead of tools like `jq` for parsing test results:
811+
812+
```bash
813+
curl -sL "http://localhost:8080/wheels/app/tests?format=json" > results.json
814+
node -e "const j = JSON.parse(require('fs').readFileSync('results.json','utf8')); console.log('Passed:', j.totalPass, '| Failed:', j.totalFail)"
815+
```
816+
727817
## Best Practices
728818

729819
For comprehensive testing best practices and advanced techniques, refer to the [TestBox Testing documentation](https://testbox.ortusbooks.com/v6.x/digging-deeper/life-cycle-methods).

0 commit comments

Comments
 (0)