Testing
Running Tests Locally
The fast path for day-to-day development is Lucee 7 + SQLite, driven by wheels test or the one-shot script tools/test-local.sh. Neither needs Docker, neither needs an external database, and both finish in seconds on a warm server. The Docker matrix exists for the other reason you run tests: proving a change works across every engine and every supported database before you push. This page covers both paths, plus the filter knobs, the raw test-runner URL, and the failure modes that eat the most time.
You’ll learn:
- The fastest way to run the suite —
wheels testandtools/test-local.sh - How to filter by directory or single spec, both from the CLI and the test-runner URL
- How to drive the Docker cross-engine matrix for pre-merge verification
- How to target a specific database (SQLite, H2, MySQL, Postgres, SQL Server, CockroachDB, Oracle)
- The cross-engine gotchas that bite first-time contributors
- The common failure modes —
JAVA_HOMEunset, port conflicts, missing Playwright JARs
Prerequisites
Section titled “Prerequisites”Before either path works, you need a handful of tools on PATH:
- Java 21+ —
brew install openjdk@21on macOS, your distro’s OpenJDK package on Linux, Adoptium on Windows. JAVA_HOMEset — only matters when no Java is otherwise discoverable: the Homebrew install wraps the binary and exportsJAVA_HOMEitself, so brew users never see the preflight refusal. On other install paths, if the CLI can’t find a JVM it refuses to start and points you at Adoptium — setJAVA_HOMEin your shell profile (export JAVA_HOME=$(/usr/libexec/java_home -v 21)on macOS).- The
wheelsCLI —brew install wheels,choco install wheels, or the install script on Linux. The Wheels CLI is built on the LuCLI runtime; we ship the runtime under thewheelsbrand so you only ever invoke it aswheels. - SQLite — usually pre-installed;
brew install sqliteif not. Needed for the default test database. - Docker Desktop or Docker Engine — only if you’re running the cross-engine matrix.
wheels --versionThe fastest path — wheels test
Section titled “The fastest path — wheels test”wheels test is the single command you’ll use most often. It detects a running Wheels server, reloads the app, hits the test runner, and prints pass/fail counts to your terminal. No Docker, no ceremony.
# Run the full suitewheels test
# Filter by directory (positional or --filter)wheels test modelwheels test --filter=controller
# Pick a reporter — simple (default), json, tapwheels test --reporter=tap
# Target a specific test database — only meaningful with --corewheels test --core --db=mysql
# CI mode: emits GitHub Actions ::error annotations per failed/errored specwheels test --ciThe supported flags today are --filter=<dir>, --reporter=<simple|json|tap>, --db=<sqlite|h2|mysql|postgres|sqlserver|oracle|cockroachdb> (only honoured with --core), --verbose / -v, --ci, --core (run the framework’s own suite instead of the app suite), --no-test-db (don’t auto-swap to <datasource>_test), and --base-path=<path> (URL prefix for subfolder-mounted apps — auto-derived from WHEELS_SUBPATH or set(subpath=...) when omitted). A positional argument acts as the filter. The CLI always requests JSON from the underlying runner and formats the result for the chosen reporter.
LuCLI one-shot: tools/test-local.sh
Section titled “LuCLI one-shot: tools/test-local.sh”tools/test-local.sh is the script every framework contributor runs before pushing. It does everything wheels test does, plus: creates the two SQLite test databases (wheelstestdb.db and wheelstestdb_tenant_b.db), starts a disposable LuCLI server on port 8080 if one isn’t already up, downloads the SQLite JDBC driver into LuCLI’s lib/ext if missing, runs the tests, parses the JSON, and prints coloured pass/fail output. On exit it stops the server if it started one.
bash tools/test-local.sh # run all core testsbash tools/test-local.sh model # run model specs onlybash tools/test-local.sh security # run security specs onlybash tools/test-local.sh browser # run browser specs (Playwright JARs required)The script accepts these short aliases as the first argument: model / models, controller / controllers, view / views, security, middleware, dispatch, migrator. Anything else is passed through as a literal directory path (dot-notation, rooted at wheels.tests.specs.).
Environment knobs:
PORT=9090 bash tools/test-local.sh— use a non-default port (handy when 8080 is in use).DB=mysql bash tools/test-local.sh— run against a different database; you’ll need to bring up the container yourself first (see the Docker matrix below).WHEELS_BROWSER_TEST_BASE_URL— auto-set tohttp://localhost:${PORT}so browser specs hit the same server the script starts. Override it if you’re pointing browser tests at a different host.
The test-runner URL
Section titled “The test-runner URL”When you need to iterate on a single spec file without rebooting anything — fastest inner loop — hit the test runner directly over HTTP. This is what wheels test and tools/test-local.sh call under the hood.
# Run everythingcurl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json"
# Filter by directorycurl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.model"
# Filter to a single spec file — fastest iteration loop (use testBundles, not directory)curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&testBundles=wheels.tests.specs.model.callbacksSpec"
# Skip populate when iterating on one spec and the schema is already in placecurl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&populate=false&testBundles=wheels.tests.specs.model.callbacksSpec"
# Scope to a single installed package's tests (e.g. wheels-sentry, wheels-i18n)curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=vendor.wheels-sentry.tests"directory= only filters down to a directory — pointing it at a single spec file matches zero bundles and runs nothing. For one file, pass the bundle’s dotted path via testBundles= instead. Also note the core endpoint only accepts directory values starting with wheels.tests (or vendor.<package>.tests); anything else is rejected and the full suite runs instead — the JSON response flags this with directoryRejected: true and a warnings[] entry rather than silently reporting green (#3083), so check those fields when a filtered run looks suspiciously slow. The populate=false query param is the other trick that makes tight inner loops possible. Populate drops and recreates every test table — a second or two on SQLite but much slower on networked databases. Once the schema is in place for the run, skipping populate shaves the wait down to just your specs executing.
Filtering tests
Section titled “Filtering tests”The runner accepts filters at every layer. Pick whichever matches your workflow.
| Method | How |
|---|---|
| By directory (CLI) | wheels test model or wheels test --filter=controller |
| By directory (script) | bash tools/test-local.sh model |
| By directory (URL) | &directory=wheels.tests.specs.model |
| By single spec file (URL) | &testBundles=wheels.tests.specs.model.callbacksSpec |
| By installed package (URL) | &directory=vendor.wheels-sentry.tests |
| Skip in source | xdescribe("...", ...) / xit("...", ...) — built into WheelsTest |
| Skip the whole populate | &populate=false on the URL (schema must already exist) |
WheelsTest does not ship tag-based include/exclude filtering at this release. Use directory filtering plus xdescribe / xit in the spec source when you need finer control.
Docker cross-engine matrix
Section titled “Docker cross-engine matrix”Every Wheels release ships compose services for the supported engines in compose.yml at the repo root. The services share a bind-mounted copy of the repo, so there’s nothing to rebuild when you edit code — just hit the port.
# Start Lucee 7 + Adobe 2025 (minimum for a cross-engine check)docker compose up -d lucee7 adobe2025
# Wait ~60s for startup, then run bothcurl -s -o /tmp/lucee7.json "http://localhost:60007/wheels/core/tests?db=sqlite&format=json"curl -s -o /tmp/adobe2025.json "http://localhost:62025/wheels/core/tests?db=sqlite&format=json"
# Summarizefor f in /tmp/lucee7.json /tmp/adobe2025.json; do python3 -c "import jsond = json.load(open('$f'))print('$f', d['totalPass'], 'pass', d['totalFail'], 'fail', d['totalError'], 'error')"doneThe service names and ports:
| Engine | Service | Port |
|---|---|---|
| Lucee 5 | lucee5 | 60005 |
| Lucee 6 | lucee6 | 60006 |
| Lucee 7 | lucee7 | 60007 |
| Adobe 2018 | adobe2018 | 62018 |
| Adobe 2021 | adobe2021 | 62021 |
| Adobe 2023 | adobe2023 | 62023 |
| Adobe 2025 | adobe2025 | 62025 |
| BoxLang | boxlang | 60001 |
Testing against specific databases
Section titled “Testing against specific databases”Each engine ships with H2 and SQLite baked in — no container needed for either. Every other database is a separate compose service you bring up alongside the engine: compose.yml ships mysql, postgres, sqlserver, cockroachdb, and oracle services. (The docker-compose.db-h2.yml / db-mysql.yml / db-postgres.yml files at the repo root are dev-stack overlays for the demo app, not test-matrix services.)
# Start Lucee 7 + MySQLdocker compose up -d lucee7 mysql
# Run against MySQLcurl -sf "http://localhost:60007/wheels/core/tests?db=mysql&format=json"Supported db values on the URL: sqlite (default for the script and local runs), h2 (default on some engines), mysql, postgres, sqlserver, oracle, cockroachdb. Oracle is currently soft-failed in CI — failures log warnings but don’t block the build.
Cross-engine gotchas
Section titled “Cross-engine gotchas”These are the runtime differences that catch first-time framework contributors. Each one has stung a real PR.
struct.map()— Lucee and Adobe resolveobj.map()as the built-in struct member function, not your CFC method. Use a different name (mapInstance()) or call the method via bracket notation.applicationscope members — Adobe CF does not support function members on theapplicationscope. Pass a plain struct context into closures instead.- Closure
thiscapture — CFML closures capturethisfrom the declaring scope at definition time. Share references across closures withvar ctx = { ref: obj }rather than reassigningthis. - Bracket-notation function calls inside closures — Adobe CF 2021 and 2023 parsers crash on
obj["key"]()inside a closure. Split into two statements:var fn = obj["key"]; fn();. - Array by-value in struct literals on Adobe — Adobe copies arrays by value in
{ arr = myArray }. Closures that append to the copy don’t affect the original. Reference via a parent struct:{ owner = parent }thenowner.arr. privatemixin functions not integrated — the framework’s$integrateComponents()copies onlypublicmethods into model and controller objects. Helpers in mixin CFCs (e.g.,vendor/wheels/model/*.cfc) must usepublicaccess with a$prefix for scoping.privatemay pass BoxLang and fail Lucee and Adobe.
Common failure modes
Section titled “Common failure modes”These are the errors you’ll actually hit. Fix-ups first, diagnosis second.
- No Java found — on non-brew installs, the CLI’s preflight refuses to start and prints a pointer to Adoptium when it can’t locate a JVM; set
JAVA_HOMEin your shell profile and re-source. Homebrew installs are immune — the brew wrapper exportsJAVA_HOMEitself. - Docker daemon not running —
docker compose upfails withCannot connect to the Docker daemon. Start Docker Desktop (macOS, Windows) orsystemctl start docker(Linux). - Port already in use —
docker compose upfails withbind: address already in useor a localwheels startis already on the port. Find the holder withlsof -i :60007and stop it. - Playwright JARs missing — browser specs skip with
this.browserTestSkipped = trueinstead of failing, so the suite stays green. Runwheels browser setuponce to install the JARs and Chromium. - Populate SQL errors — read the error. “Table already exists” means a stale table from a previous engine-specific run; add a
DROP TABLE IF EXISTSfirst. “Syntax error near CURRENT_TIMESTAMP” means cross-engine SQL drift; useNOW()— it works on every supported engine. - Flaky
wheels newin parallel harness runs — a known LuCLI race (framework gap tracker #11). Retry once, or serialize the runs until the fix lands. - Server not responding after
compose up— engines need 30–90 seconds to cold-start.curl -I http://localhost:60007/returns connection-refused until the engine is ready. If it stays down after two minutes, checkdocker compose logs <service>. - “1 error(s)” with a bundle path but no failing assertion — the BDD runner caught a load-time or setup error for that spec file. Two common causes: (1)
it()called directly insiderun()without an enclosingdescribe()block, or (2)beforeAll()throwing an exception. The runner captures both against the bundle and populatesglobalException— check the spec file at the path shown in the summary for the underlying throw.
Cleanup
Section titled “Cleanup”Clean up is cheap but easy to forget. Left-over SQLite files and running containers are the usual culprits behind “why did my next run behave weirdly”.
# Stop all engine containersdocker compose down
# Purge SQLite test databases between runsrm -f wheelstestdb.db wheelstestdb_tenant_b.db
# Stop any local server the script startedwheels stop