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.
Counting queries
Section titled “Counting queries”From Wheels 4.2, a spec can assert how many SQL statements a call sends, which makes N+1 queries, missing caches and unexpected writes testable:
| Function | Fails unless |
|---|---|
assertQueries(count, callback) | callback sends exactly count statements |
assertNoQueries(callback) | callback sends none |
assertQueriesMatch(pattern, callback, count) | at least one statement (or exactly count) matches the regular expression pattern, case-insensitive |
assertNoQueriesMatch(pattern, callback) | no statement matches pattern |
recordQueries(callback) | never fails: returns the statements as an array of {sql, dataSource} |
component extends="wheels.WheelsTest" { function run() { describe("Post listing", () => { it("loads authors in the same query", () => { var posts = assertQueries(1, () => model("Post").findAll(include="author")); expect(posts.recordCount).toBeGT(0); });
it("doesn't touch the comments table", () => { assertNoQueriesMatch("comments", () => model("Post").findAll()); }); }); }}- The assertions return what
callbackreturned, so a call can stay inline. - Every function takes an optional
dataSourcethat counts only statements sent to that datasource, which is useful in a multi-tenant app. - A failure lists the statements that ran, with each bound value replaced by
?, so a failure can’t put bound data into a CI log. A value written into the SQL text itself (parameterize=false) appears as written. - Assertions can be nested: each counts its own callback’s statements.
- What counts: every statement the model layer sends, including finders,
count(),save(),create(),update(),delete(),updateAll()anddeleteAll(). - What doesn’t count:
- a finder answered from the per-request query cache, because it sends no SQL (pass
reload=trueto force the query); - raw
queryExecute()/cfquery, including the framework features built on it: advisory locks, the migration lock, the job store and SQL Server’s settings probes; - transaction control;
- requests made through the test client, which run in a request of their own.
- a finder answered from the per-request query cache, because it sends no SQL (pass
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. - A cached finder can return stale rows. Finders cache their results for the rest of the request (
cacheQueriesDuringRequest, on by default), and a spec runs in one request. A save through a model clears that model’s cached queries, so re-reading the same model after its own save is fine — but the cache stays stale when data changes another way: raw SQL, a write through a different model than the one you re-query (e.g. aCommentcreate vs a cachedmodel("Post").findAll(include="comments")), or a change made in a separate test-client request. Passreload=trueto the finder, or clear the cache withmodel("Post").forgetCachedQueries()(one model) orforgetCachedQueries(all=true)(every model), to read fresh data. - 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.
Related guides
Section titled “Related guides”Traveling the clock
Section titled “Traveling the clock”Time-dependent behaviour — a cache that expires, a rate-limit window that rolls over, a createdAt that must fall on a certain day — is hard to test against the real clock. travelTo(moment, callback) freezes the framework clock for the duration of the callback:
travelTo("2026-10-04 12:00:00", function() { // code under test sees 12:00:00 — model timestamps, cache expiry // and rate-limit windows all read this instant});
// roll the clock forward to prove a window reset, deterministically and without sleepingtravelTo("2026-10-04 12:02:00", function() { expect(limiter.handle(request = req, next = nextFn)).toBe("ok");});Framework features that read the clock through the seam ($now() / $tick()) honour it: cache expiry and culling, the RateLimiter rate-limit windows, and model createdAt / updatedAt timestamps. The override is request-scoped and restored after the callback returns — even if it throws — so it never leaks into another spec. It applies to in-process code; a separate $testClient() request runs its own request and keeps the real clock.