Testing
Model Tests
Model tests are the bulk of a healthy suite. A single spec file mirrors a single model — its validations, callbacks, custom methods, associations, and scopes. They run fast (no HTTP, no rendering) and give precise failure messages when a rule breaks. This page walks the BDD patterns for each category, the isolation model between specs, and the traps that catch people out.
You’ll learn:
- The canonical shape of a model spec —
describe,it,expect - How to assert validations — presence, length, uniqueness
- How to test callbacks that mutate a record before or after save
- How to exercise custom model methods, associations, and scopes
- How WheelsTest handles database state between specs
- The common pitfalls that cause “works alone, fails in the suite” failures
Minimal spec shape
Section titled “Minimal spec shape”Every model spec is a CFC under tests/specs/models/ that extends wheels.WheelsTest. The run() method holds one or more describe blocks, each grouping related it assertions.
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(); }); }); }}Three things to note:
- The spec extends
wheels.WheelsTest— the BDD base. The legacywheels.Testis the RocketUnit runner and doesn’t havedescribe,it, orexpect. model("Post").new(...)builds an in-memory instance; it never touches the database. Calling.valid()runs the validation pass and returns a boolean.errorsOn("title")returns an array of error messages for that property.toBeArray()asserts the return shape — the matcher resolves to a type check via the dynamic matcher protocol.
Testing validations
Section titled “Testing validations”Validations are the easiest category to test: build a record that should fail, assert .valid() returns false, then confirm an error landed on the expected property.
Presence
Section titled “Presence”describe("Post validations", () => { it("requires a title", () => { var post = model("Post").new(body="body text"); expect(post.valid()).toBeFalse(); expect(post.errorsOn("title")).toBeArray(); });
it("is valid with a title and body", () => { var post = model("Post").new(title="Hello", body="body"); expect(post.valid()).toBeTrue(); });});errorsOn always returns an array — empty when the property has no errors, populated when it does. toBeArray works whether the array is empty or not; to assert at least one error, chain toHaveLength or check ArrayLen().
Length
Section titled “Length”describe("Post length rules", () => { 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 titles at the boundary", () => { var post = model("Post").new(title=RepeatString("x", 120), body="body"); expect(post.valid()).toBeTrue(); });});Two assertions for every length rule: one just over the boundary, one at the boundary. The boundary case prevents off-by-one regressions when someone “fixes” the rule later.
Uniqueness
Section titled “Uniqueness”Uniqueness is the first rule that has to touch the database — the model queries for a duplicate before reporting the error. Create one record, then try to build a second with the same value.
describe("Post uniqueness", () => { it("rejects a duplicate slug", () => { model("Post").create(title="First", body="B", slug="hello"); var dup = model("Post").new(title="Second", body="B", slug="hello"); expect(dup.valid()).toBeFalse(); expect(dup.errorsOn("slug")).toBeArray(); });});create(...) builds and saves in one call — it returns the object whether the save succeeded or not, so check isPersisted() (or its inverse, isNew()) if you need to be sure.
Testing callbacks
Section titled “Testing callbacks”Callbacks run around save, update, and delete. To test one, trigger the lifecycle event and check the side effect. A beforeSave that generates a slug from the title is a classic case:
describe("Post callbacks", () => { it("generates slug from title on create", () => { var post = model("Post").create(title="Hello World", body="B"); expect(post.slug).toBe("hello-world"); });
it("does not overwrite an explicit slug", () => { var post = model("Post").create(title="Hello World", body="B", slug="custom"); expect(post.slug).toBe("custom"); });});toBe compares by value equality (Wheels’ matcher calls CFML’s isEqual under the hood), so it works cleanly for strings, numbers, and booleans. For deep equality on arrays and structs, toBe still works — the underlying comparison recurses.
Testing custom methods
Section titled “Testing custom methods”Any public function you add to a model is testable the same way. Build an instance, call the method, assert the return value.
describe("Post##isPublished", () => { it("returns true for published posts with a past publishedAt", () => { var post = model("Post").new(status="published", publishedAt=Now()); expect(post.isPublished()).toBeTrue(); });
it("returns false for draft posts", () => { var post = model("Post").new(status="draft", publishedAt=Now()); expect(post.isPublished()).toBeFalse(); });
it("returns false for future publishedAt", () => { var post = model("Post").new(status="published", publishedAt=DateAdd("d", 1, Now())); expect(post.isPublished()).toBeFalse(); });});One it per branch of the method. When the method grows a fourth case, you add a fourth it; the first three don’t move.
Testing associations
Section titled “Testing associations”Associations generate methods on the parent record (post.comments(), post.createComment(...), post.commentCount()) and in many cases cascade to the child (dependent="delete"). Exercise both sides.
describe("Post associations", () => { it("counts comments via the association", () => { var post = model("Post").create(title="T", body="B", status="published"); post.createComment(author="Alice", body="C1"); post.createComment(author="Bob", body="C2"); expect(post.commentCount()).toBe(2); });
it("deletes comments when the post is deleted with dependent=delete", () => { var post = model("Post").create(title="T", body="B", status="published"); post.createComment(author="Alice", body="C1"); post.createComment(author="Bob", body="C2"); post.delete(); expect(model("Comment").count(where="postId=##post.id##")).toBe(0); });});Testing scopes
Section titled “Testing scopes”Scopes are reusable query fragments declared in config(). Named scopes and enum-generated scopes both chain off the model class and return a query builder — assert against .count() or .get().recordCount.
describe("Post scopes", () => { it("published() returns only published posts", () => { model("Post").create(title="Live", body="B", status="published"); model("Post").create(title="Wip", body="B", status="draft"); expect(model("Post").published().count()).toBe(1); expect(model("Post").draft().count()).toBe(1); });
it("composes scopes with additional conditions", () => { model("Post").create(title="Live1", body="B", status="published", featured=true); model("Post").create(title="Live2", body="B", status="published", featured=false); expect(model("Post").published().where("featured", true).count()).toBe(1); });});published() and draft() above are auto-generated from enum(property="status", values="draft,published,archived"). Named scopes declared with scope(name="active", where="...") work identically.
Test isolation
Section titled “Test isolation”WheelsTest does not wrap each it in a transaction. The database state carries across specs within a run. Two mechanisms keep things predictable:
tests/populate.cfmruns once at the start of a test run. It drops the test tables, recreates them, and seeds any baseline data. Every spec in the run sees the same starting schema and seed rows.- Manual transactions inside an
itblock. When a spec needs to roll back its own writes (for example, to test a delete without poisoning later specs), wrap the work in atransaction { ... transaction action="rollback"; }block. The framework’s own callback specs use this pattern.
it("tests a destructive operation in isolation", () => { transaction { var post = model("Post").create(title="T", body="B"); post.delete(); expect(model("Post").findByKey(post.id)).toBeFalse(); transaction action="rollback"; }});The default transaction mode in the test runner is none — writes persist across specs unless you roll them back explicitly. If a spec that passes on its own fails when run with the rest of the suite, look for leftover rows from an earlier spec. The Fixtures & Test Data guide covers the full setup pattern, populate.cfm contents, and strategies for keeping specs independent.
Common pitfalls
Section titled “Common pitfalls”- Mixed positional and named args.
hasMany("comments", dependent="delete")looks natural but breaks — Wheels functions can’t mix the two styles. Use all-named (hasMany(name="comments", dependent="delete")) or all-positional (hasMany("comments")). This is the single most common source of model spec failures. - Treating
findAll()as an array.model("Post").findAll()returns a CFML query, not an array.<cfloop query="posts">works;<cfloop array="#posts#">raises a type error. Check.recordCountfor size. - Forgetting
.reload()after a callback writes server-generated columns. AbeforeCreatethat setsslugfrom a databaseDEFAULTexpression, or anafterSavethat updates a counter, may not be reflected on the in-memory instance. Callpost.reload()before asserting. - Reaching for matchers that don’t exist.
toEqual,toBeTruthy, andtoBeFalsyare not shipped — neither is in the matcher set. UsetoBefor value equality (it wrapsisEqual, which recurses into structs and arrays),toBeTrueortoBeFalsefor booleans, andtoBeArray/toBeStruct/toBeQueryfor type checks. toBeInstanceOf("component")is not cross-engine.getMetadata(obj).typereturns"component"on Lucee and Adobe CF but the fully-qualified class name on BoxLang, so this assertion passes on two engines and silently fails on the third. When asserting that a finder returned a Wheels model instance, useexpect(found).toBeWheelsModel()instead.