Skip to content

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 test and tools/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_HOME unset, port conflicts, missing Playwright JARs

Before either path works, you need a handful of tools on PATH:

  • Java 21+brew install openjdk@21 on macOS, your distro’s OpenJDK package on Linux, Adoptium on Windows.
  • JAVA_HOME set — only matters when no Java is otherwise discoverable: the Homebrew install wraps the binary and exports JAVA_HOME itself, 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 — set JAVA_HOME in your shell profile (export JAVA_HOME=$(/usr/libexec/java_home -v 21) on macOS).
  • The wheels CLIbrew 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 the wheels brand so you only ever invoke it as wheels.
  • SQLite — usually pre-installed; brew install sqlite if not. Needed for the default test database.
  • Docker Desktop or Docker Engine — only if you’re running the cross-engine matrix.
Terminal window
wheels --version

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.

your shell — in the wheels repo root
# Run the full suite
wheels test
# Filter by directory (positional or --filter)
wheels test model
wheels test --filter=controller
# Pick a reporter — simple (default), json, tap
wheels test --reporter=tap
# Target a specific test database — only meaningful with --core
wheels test --core --db=mysql
# CI mode: emits GitHub Actions ::error annotations per failed/errored spec
wheels test --ci

The 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.

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.

your shell — in the wheels repo root
bash tools/test-local.sh # run all core tests
bash tools/test-local.sh model # run model specs only
bash tools/test-local.sh security # run security specs only
bash 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 to http://localhost:${PORT} so browser specs hit the same server the script starts. Override it if you’re pointing browser tests at a different host.

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.

direct HTTP access (dev server running)
# Run everything
curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json"
# Filter by directory
curl "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 place
curl "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.

The runner accepts filters at every layer. Pick whichever matches your workflow.

MethodHow
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 sourcexdescribe("...", ...) / 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.

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.

your shell — in the wheels repo root
# Start Lucee 7 + Adobe 2025 (minimum for a cross-engine check)
docker compose up -d lucee7 adobe2025
# Wait ~60s for startup, then run both
curl -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"
# Summarize
for f in /tmp/lucee7.json /tmp/adobe2025.json; do
python3 -c "
import json
d = json.load(open('$f'))
print('$f', d['totalPass'], 'pass', d['totalFail'], 'fail', d['totalError'], 'error')
"
done

The service names and ports:

EngineServicePort
Lucee 5lucee560005
Lucee 6lucee660006
Lucee 7lucee760007
Adobe 2018adobe201862018
Adobe 2021adobe202162021
Adobe 2023adobe202362023
Adobe 2025adobe202562025
BoxLangboxlang60001

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.)

your shell — multi-database
# Start Lucee 7 + MySQL
docker compose up -d lucee7 mysql
# Run against MySQL
curl -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.

These are the runtime differences that catch first-time framework contributors. Each one has stung a real PR.

  • struct.map() — Lucee and Adobe resolve obj.map() as the built-in struct member function, not your CFC method. Use a different name (mapInstance()) or call the method via bracket notation.
  • application scope members — Adobe CF does not support function members on the application scope. Pass a plain struct context into closures instead.
  • Closure this capture — CFML closures capture this from the declaring scope at definition time. Share references across closures with var ctx = { ref: obj } rather than reassigning this.
  • 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 } then owner.arr.
  • private mixin functions not integrated — the framework’s $integrateComponents() copies only public methods into model and controller objects. Helpers in mixin CFCs (e.g., vendor/wheels/model/*.cfc) must use public access with a $ prefix for scoping. private may pass BoxLang and fail Lucee and Adobe.

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_HOME in your shell profile and re-source. Homebrew installs are immune — the brew wrapper exports JAVA_HOME itself.
  • Docker daemon not runningdocker compose up fails with Cannot connect to the Docker daemon. Start Docker Desktop (macOS, Windows) or systemctl start docker (Linux).
  • Port already in usedocker compose up fails with bind: address already in use or a local wheels start is already on the port. Find the holder with lsof -i :60007 and stop it.
  • Playwright JARs missing — browser specs skip with this.browserTestSkipped = true instead of failing, so the suite stays green. Run wheels browser setup once 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 EXISTS first. “Syntax error near CURRENT_TIMESTAMP” means cross-engine SQL drift; use NOW() — it works on every supported engine.
  • Flaky wheels new in 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, check docker 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 inside run() without an enclosing describe() block, or (2) beforeAll() throwing an exception. The runner captures both against the bundle and populates globalException — check the spec file at the path shown in the summary for the underlying throw.

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”.

your shell
# Stop all engine containers
docker compose down
# Purge SQLite test databases between runs
rm -f wheelstestdb.db wheelstestdb_tenant_b.db
# Stop any local server the script started
wheels stop