Skip to content

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.

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

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.

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 boolean true
  • expect(x).toBeFalse() — exact boolean false
  • expect(x).toBe(y) — equality, deep-comparing structs and arrays
  • expect(x).toBeArray() / toBeStruct() / toBeQuery() — type checks
  • expect(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.

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.

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

  1. 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:

your shell
wheels browser setup

The installer downloads the JARs into lib/, Chromium into ~/Library/Caches (macOS) or ~/.cache (Linux), and writes a manifest so CI knows what’s cached.

  1. 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:

  • browserDescribe wraps describe with a beforeEach/afterEach pair that creates a fresh Playwright page for every it. Without it, one test’s navigation state would leak into the next.
  • ##post-title is CFML escaping a literal # inside a string. At runtime the selector is #post-title — the CSS id the textField(objectName="post", property="title") helper emits (Wheels joins object + property with a dash). Without the double ##, CFML would try to evaluate post-title as 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 — the Delete button and the Post comment button — so a bare button[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 like input[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’s fetch + history.pushState, which doesn’t complete on the same tick as the click. assertUrlContains and assertSee read state synchronously, so without an explicit wait they’d fire before navigation lands and you’d see Expected URL to contain '/posts', got 'http://localhost:8080/signup'. waitForUrl accepts Playwright glob patterns; waitForText waits 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 before wheels browser setup, for example. The flag is set by beforeAll when the JARs can’t be loaded.
your shell
wheels test

Expected output (format may vary across releases):

illustrative — exact format varies
Running tests in tests/specs...
PostSpec: 4 passed
PostsControllerSpec: 1 passed
SignupFlowSpec: 1 passed
Total: 6 passed, 0 failed, 0 errors

To run only the browser specs:

your shell
wheels test --filter=browser

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

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.

Quick recap of the feature list:

  • Post model with title, 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 SessionStrategy variants
  • 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.

Terminal window
wheels --version

Three things to verify before moving on:

  1. wheels test passes both the model spec and the controller spec.
  2. wheels browser setup completes without error, and wheels test --filter=browser runs the browser spec green — Chromium launches, the signup-to-post-to-comment flow runs end-to-end.
  3. 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.

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