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.

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