Skip to content

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

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.

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();
});
});
}
}

Three things to note:

  • The spec extends wheels.WheelsTest — the BDD base. The legacy wheels.Test is the RocketUnit runner and doesn’t have describe, it, or expect.
  • 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.

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.

tests/specs/models/PostSpec.cfc
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().

tests/specs/models/PostSpec.cfc
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 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.

tests/specs/models/PostSpec.cfc
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.

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:

tests/specs/models/PostSpec.cfc
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.

Any public function you add to a model is testable the same way. Build an instance, call the method, assert the return value.

tests/specs/models/PostSpec.cfc
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.

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.

tests/specs/models/PostSpec.cfc
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);
});
});

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.

tests/specs/models/PostSpec.cfc
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.

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:

FunctionFails 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 callback returned, so a call can stay inline.
  • Every function takes an optional dataSource that 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() and deleteAll().
  • What doesn’t count:
    • a finder answered from the per-request query cache, because it sends no SQL (pass reload=true to 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.

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.cfm runs 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 it block. When a spec needs to roll back its own writes (for example, to test a delete without poisoning later specs), wrap the work in a transaction { ... transaction action="rollback"; } block. The framework’s own callback specs use this pattern.
illustrative — do not type
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.

  • 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 .recordCount for size.
  • Forgetting .reload() after a callback writes server-generated columns. A beforeCreate that sets slug from a database DEFAULT expression, or an afterSave that updates a counter, may not be reflected on the in-memory instance. Call post.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. a Comment create vs a cached model("Post").findAll(include="comments")), or a change made in a separate test-client request. Pass reload=true to the finder, or clear the cache with model("Post").forgetCachedQueries() (one model) or forgetCachedQueries(all=true) (every model), to read fresh data.
  • Reaching for matchers that don’t exist. toEqual, toBeTruthy, and toBeFalsy are not shipped — neither is in the matcher set. Use toBe for value equality (it wraps isEqual, which recurses into structs and arrays), toBeTrue or toBeFalse for booleans, and toBeArray / toBeStruct / toBeQuery for type checks.
  • toBeInstanceOf("component") is not cross-engine. getMetadata(obj).type returns "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, use expect(found).toBeWheelsModel() instead.

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 sleeping
travelTo("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.