Start Here
Part 7: Testing and Deploying
You’ll write one spec of each flavor the framework ships — model, controller, browser — run them, then see how a Wheels app reaches production.
You’ll learn:
- WheelsTest BDD basics —
describe,it,expect - How to write a model spec that exercises validations
- How to write a controller spec that checks the happy path
- How to drive a real Chromium browser through the full signup flow
- An overview of deploying a Wheels app
Estimated time: 35 minutes.
Where we left off
Section titled “Where we left off”Part 6 added authentication. The schema is posts(id, userId, title, body, status, publishedAt, createdAt, updatedAt), comments(id, postId, author, body, createdAt, updatedAt), and users(id, email, passwordHash, passwordSalt, createdAt, updatedAt). The Posts controller has an authenticate filter that keeps create, update, and delete behind a login.
Directoryblog
Directoryapp
Directorycontrollers
- Controller.cfc
- Main.cfc
- Posts.cfc
- Comments.cfc
- Users.cfc
- Sessions.cfc
Directorydb
- seeds.cfm
Directorymigrator
Directorymigrations
- 20260419120000_create_posts_table.cfc
- 20260419130000_create_comments_table.cfc
- 20260419140000_create_users_table.cfc
- 20260419150000_add_user_to_posts.cfc
Directorymodels
- Post.cfc
- Comment.cfc
- User.cfc
Directoryviews
- layout.cfm
- main
- posts
- comments
- users
- sessions
Directoryconfig
- routes.cfm
- services.cfm
- settings.cfm
Directorydb
- development.sqlite
Why test
Section titled “Why test”You’ve been testing this app by clicking through the browser. That works for a hundred lines of code. It stops working around the time you have three models, five controllers, and a coworker. Automated tests are what let you change code without fear.
Wheels ships a BDD-style test runner called WheelsTest. It’s fast — a fresh app’s full suite runs in seconds — and it has three shapes you’ll use: a model spec for validations and callbacks, a controller spec for request/response behavior, and a browser spec that drives a real Chromium through the rendered HTML. You’ll write one of each.
Anatomy of a WheelsTest spec
Section titled “Anatomy of a WheelsTest spec”Every spec is a CFC that extends wheels.WheelsTest. The run() method contains describe(...) blocks. Inside each describe, it(...) blocks assert specific behaviors. Assertions are chained off expect(...):
expect(x).toBeTrue()— exact booleantrueexpect(x).toBeFalse()— exact booleanfalseexpect(x).toBe(y)— equality, deep-comparing structs and arraysexpect(x).toBeArray()/toBeStruct()/toBeQuery()— type checksexpect(x).toInclude(substring)/toHaveKey(name)/toHaveLength(n)— content checks
That’s enough vocabulary to cover the first hundred specs you’ll write. See Testing for the full matcher list.
Model spec — Post validations
Section titled “Model spec — Post validations”Start with the Post model. You added four validation rules in Part 4: title is required, body is required, title caps at 120 chars, status is constrained to the enum. A model spec hits each rule in isolation.
-
Create
tests/specs/models/PostSpec.cfc:component extends="wheels.WheelsTest" {function run() {describe("Post", () => {it("requires a title", () => {var post = model("Post").new(body="some body");expect(post.valid()).toBeFalse();expect(post.errorsOn("title")).toBeArray();});it("requires a body", () => {var post = model("Post").new(title="some title");expect(post.valid()).toBeFalse();expect(post.errorsOn("body")).toBeArray();});it("rejects titles over 120 characters", () => {var post = model("Post").new(title=RepeatString("x", 121), body="body");expect(post.valid()).toBeFalse();expect(post.errorsOn("title")).toBeArray();});it("accepts valid input", () => {var post = model("Post").new(title="OK", body="body", status="draft");expect(post.valid()).toBeTrue();});});}}
Each it block builds a fresh Post object with .new(...), which runs the constructor but never touches the database. Calling .valid() triggers the validation pass and returns a boolean. errorsOn("title") returns the list of errors attached to the title field — truthy when any exist, empty when none.
Four independent cases, four independent assertions. If you later add a validatesFormatOf on the title, you add a fifth it block; the four existing cases don’t change.
Controller spec — Posts#index
Section titled “Controller spec — Posts#index”Controller specs verify that a request dispatches to the right action and comes back with the right response. The framework’s TestClient drives this — a real HTTP client against your test server. The $testClient() helper on WheelsTest constructs one; .get(path) executes the request; chainable assertions like .assertOk() check the response.
For Phase 1 of the tutorial, one smoke-test spec is enough: verify that GET /posts comes back with a 200. Deeper patterns (asserting rendered content, stubbing the model, exercising redirects) live in the Testing section.
-
Create
tests/specs/controllers/PostsControllerSpec.cfc:component extends="wheels.WheelsTest" {function run() {describe("PostsController", () => {it("loads posts for the index page", () => {$testClient().get("/posts").assertOk();});});}}
$testClient().get("/posts") makes a real HTTP request to your test server — the full pipeline fires: middleware, route match, controller instantiation, config(), filter chain, index() action, view render. .assertOk() asserts a 200 response. Chainable assertions cover redirects, response bodies, headers, cookies, and JSON. See Controller Tests for the full API.
Browser spec — full signup to post to comment
Section titled “Browser spec — full signup to post to comment”Browser specs drive a real Chromium through the rendered HTML. Behind the scenes, Wheels’ BrowserTest harness wraps Playwright Java in a fluent DSL — every click, fill, and assertion is one chained method call.
Playwright ships as a set of JARs plus a Chromium binary, ~370MB in total. Install it once:
wheels browser setupThe installer downloads the JARs into lib/, Chromium into ~/Library/Caches (macOS) or ~/.cache (Linux), and writes a manifest so CI knows what’s cached.
-
Create
tests/specs/browser/SignupFlowSpec.cfc:component extends="wheels.wheelstest.BrowserTest" {this.browserEngine = "chromium";function run() {browserDescribe("Full signup flow", () => {it("signs up, creates a post, adds a comment", () => {if (this.browserTestSkipped) return;this.browser.visitRoute("signup").fill("input[name='user[email]']", "alice@example.com").fill("input[name='user[password]']", "hunter2").click("button[type=submit]").waitForUrl("**/posts").assertUrlContains("/posts");this.browser.visitRoute("posts").click("a[href*='/posts/new']").fill("##post-title", "My first post").fill("##post-body", "Hello Wheels").click("button[type=submit]").waitForText("My first post").assertSee("My first post");this.browser.fill("input[name='comment[author]']", "Bob").fill("textarea[name='comment[body]']", "Great post").click("turbo-frame##new_comment button[type=submit]").waitForText("Great post").assertSee("Great post");});});}}
Things to notice:
browserDescribewrapsdescribewith abeforeEach/afterEachpair that creates a fresh Playwright page for everyit. Without it, one test’s navigation state would leak into the next.##post-titleis CFML escaping a literal#inside a string. At runtime the selector is#post-title— the CSS id thetextField(objectName="post", property="title")helper emits (Wheels joins object + property with a dash). Without the double##, CFML would try to evaluatepost-titleas an expression and throw.turbo-frame##new_comment button[type=submit]is the same##escape applied to a frame-scoped selector. The post show page has two submit buttons — theDeletebutton and thePost commentbutton — so a barebutton[type=submit]would match both and Playwright’s strict-mode locator would refuse to act. Scoping to the new-comment frame makes the click unambiguous.- The signup form uses plain
<input name="user[email]">without an id, so the spec targets it by attribute selector instead. Attribute selectors likeinput[name='user[email]']are robust against future form refactors. - The fluent DSL chains:
.visitRoute("signup").fill(...).click(...).waitForUrl("**/posts").assertUrlContains("/posts")is one statement. Each method returns the browser object, so the chain keeps going until you call a terminal like.assertSee(...)or let the statement end. .waitForUrl("**/posts")and.waitForText("My first post")are not optional — they bridge the async gap between.click()and the assertion. Submitting a form goes through Turbo Drive’sfetch+history.pushState, which doesn’t complete on the same tick as the click.assertUrlContainsandassertSeeread state synchronously, so without an explicit wait they’d fire before navigation lands and you’d seeExpected URL to contain '/posts', got 'http://localhost:8080/signup'.waitForUrlaccepts Playwright glob patterns;waitForTextwaits for visible text anywhere on the page (handy when a Turbo Stream appends content but the URL doesn’t change).if (this.browserTestSkipped) return;keeps the spec green on machines without Playwright installed — fresh CI runners beforewheels browser setup, for example. The flag is set bybeforeAllwhen the JARs can’t be loaded.
Run the tests
Section titled “Run the tests”wheels testExpected output (format may vary across releases):
Running tests in tests/specs... PostSpec: 4 passed PostsControllerSpec: 1 passed SignupFlowSpec: 1 passedTotal: 6 passed, 0 failed, 0 errorsTo run only the browser specs:
wheels test --filter=browserThe --filter flag narrows the run to specs whose path contains the argument. Other filters accept models, controllers, or a full directory path. The exact syntax is still settling; see CLI Reference for the current options.
Deployment overview
Section titled “Deployment overview”A Wheels app is a Java web application. You package the app (your code plus vendor/wheels/ plus your config) and run it on a CFML engine — Lucee or Adobe ColdFusion — on whatever server you choose. Two paths are common in 2026: a Docker container shipped with Kamal, or a plain VM provisioned with Ansible, Chef, or Puppet. The tutorial recommends Kamal.
Kamal is the deploy tool extracted from the Rails ecosystem. You describe your app, your servers, and your domain in a config/deploy.yml; kamal setup provisions the box the first time (installs Docker, pulls your image, wires up a reverse proxy with automatic TLS); kamal deploy ships every subsequent change with zero-downtime rolling restarts. Wheels ships a starter Dockerfile and a Kamal config template so you can get from “tests pass” to “it’s live” in one afternoon. The full walkthrough — image build, secrets, TLS, migrations at boot — lives in Deployment & Operations.
Whichever path you take, the production checklist is the same: serve HTTPS, set environment=production in config/environment.cfm, disable the development reload endpoint, point the app at a real database (Postgres or MySQL, not the SQLite file you’ve been using), set up log rotation, and run migrations automatically on each deploy. Each of those gets a dedicated page in the Deployment section.
What you built
Section titled “What you built”Quick recap of the feature list:
Postmodel withtitle,body,status(enum),publishedAt- Migrations and seeds
- Full CRUD scaffold rendered with simple.css default styling and Turbo Drive page transitions
- Validations with inline errors via Turbo Frames
- Comments via nested resources with Turbo Stream appends
- User signup, login, and logout — hand-rolled and built-in
SessionStrategyvariants - Ownership filter: users edit only their own posts
- Model spec, controller spec, and one browser spec covering the full flow
You know Wheels now. Not all of it, but enough to build real things. Time to broaden.
Smoke test
Section titled “Smoke test”wheels --versionCheckpoint
Section titled “Checkpoint”Three things to verify before moving on:
wheels testpasses both the model spec and the controller spec.wheels browser setupcompletes without error, andwheels test --filter=browserruns the browser spec green — Chromium launches, the signup-to-post-to-comment flow runs end-to-end.- Click through the running app one more time: sign up, create a post, leave a comment, log out, log back in, try to edit someone else’s post and get redirected. Every part of the tutorial still works end-to-end.
Troubleshooting
Section titled “Troubleshooting”“Model spec fails with cannot find Post.cfc.” The test runner runs against db/test.sqlite (separate from your dev DB so chapter 6’s manual signup doesn’t leak in). The framework auto-runs tests/populate.cfm the first time the test DB is empty — that file ships with wheels new and runs your migrations against the test database. If you’ve customised populate.cfm and removed the migrateToLatest() call, restore it. Pass --no-test-db if you want the run to share the dev datasource. See Fixtures & Test Data for ways to add test-specific seed data.
“Browser spec hangs for thirty seconds, then times out.” Playwright isn’t installed. Run wheels browser setup — it downloads ~370MB and prints a progress bar. Once it completes, re-run the spec. If it still hangs, check that this.browserTestSkipped isn’t incorrectly set; the guard clause should short-circuit when the JARs are missing, not when they’re present.
“expect(...).toBeTrue is not a function.” The spec CFC extends the wrong base. Use component extends="wheels.WheelsTest" — with a capital T in WheelsTest. The legacy wheels.Test is the RocketUnit base and doesn’t have the BDD matchers.