Blog

Luis Majano

September 11, 2026

Spread the word


Share your thoughts

TestBox 7.1.0 is here, and it is the largest single expansion of our assertion and expectation library since TestBox was born.

47 new matchers, assertions and helpers ship in this release. 29 of them are exclusive to BoxLang.

That ratio is not an accident. BoxLang gives us real language primitives that CFML simply does not have: a native Set type, a native Range type with the .. operator, and the dataNavigate() BIF. Where the language gives us more to work with, TestBox gives you more to assert with.

Here is how the release breaks down.

Feature areaNew APIsEngines
Set expectations9BoxLang only
Range expectations14BoxLang only
Data Navigator expectations6BoxLang only
New matchers8BoxLang + CFML
New assertion BIFs5BoxLang + CFML
Collection expectation modes3BoxLang + CFML
Expectation context1BoxLang + CFML
Grouped assertions1BoxLang + CFML
Total47

Install it now:

box install testbox@7.1.0

The BoxLang Story

Set Expectations: Stop Sorting Arrays to Compare Them

If you have ever written this, you know the pain:

// The old way: sort both sides and pray
var actual = myService.getRoles().toArray().sort( "textnocase" )
var expected = [ "admin", "editor" ].sort( "textnocase" )
expect( actual ).toBe( expected )

You are not testing set membership. You are testing that two arrays sorted the same way, which is an implementation detail you do not care about and which will break the moment ordering shifts.

BoxLang has a real Set. TestBox 7.1 now speaks it natively:

var roles = setOf( "admin", "editor", "viewer" )

expect( roles ).toBeASet()
expect( roles ).toEqualSet( setOf( "viewer", "admin", "editor" ) )   // order is irrelevant

Nine matchers cover the full algebra:

// Membership relationships
expect( setOf( "admin" ) ).toBeSubsetOf( roles )
expect( roles ).toBeSupersetOf( setOf( "admin", "editor" ) )
expect( setOf( "read" ) ).toBeDisjointFrom( setOf( "write", "delete" ) )

// Set operations, asserted directly
expect( setOf( 1 ) ).toHaveUnion( setOf( 2 ), setOf( 1, 2 ) )
expect( setOf( 1, 2 ) ).toHaveIntersection( setOf( 2, 3 ), setOf( 2 ) )
expect( setOf( 1, 2 ) ).toHaveDifference( setOf( 2 ), setOf( 1 ) )
expect( setOf( 1, 2 ) ).toHaveSymmetricDifference( setOf( 2, 3 ), setOf( 1, 3 ) )

Where this really pays off is permission and feature-flag testing, where the assertion you actually want is "at least these, and definitely not those":

describe( "Menu permissions", () => {

    it( "shows only what the user may reach", () => {
        var visible = menuService.visibleFor( user )

        expect( visible ).toBeSupersetOf( setOf( "dashboard", "reports" ) )
        expect( visible ).toBeDisjointFrom( setOf( "admin", "billing" ) )
    } )

} )

Read that assertion out loud. It says exactly what the business rule says. That is the whole point.

Every matcher has a negated twin: notToBeASet(), notToEqualSet(), notToBeSubsetOf(), and so on.

Range Expectations: 14 Matchers for BoxLang Ranges

BoxLang ranges are created with the .. operator and stepped with .step( n ). TestBox 7.1 adds fourteen matchers so you can assert against them without picking them apart into endpoints first.

var base    = 1..10
var stepped = ( 0..100 ).step( 5 )
var chars   = "a".."z"
var dates   = createDate( 2024, 1, 1 )..createDate( 2024, 1, 31 )

Containment, read from whichever side makes the sentence work:

expect( base ).toBeRange()
expect( base ).toContainValue( 5 )
expect( base ).toContainRange( 3..7 )
expect( 8 ).toBeInRange( base )

expect( 0 ).toBeBeforeRange( base )
expect( 11 ).toBeAfterRange( base )

Shape and direction, including the unbounded and half-bounded forms:

expect( 1..10 ).toBeBounded()
expect( .. ).toBeUnbounded()
expect( 1.. ).toBeHalfBounded()
expect( 1..10 ).toBeIterable()

expect( 1..10 ).toBeAscending()
expect( 10..1 ).toBeDescending()

Step and clamp, which are the ones you will reach for in real pagination and bounds code:

expect( stepped ).toHaveStep( 5 )

expect( base ).toClampTo( 15, 10 )   // 15 clamps down to 10
expect( base ).toClampTo( -3, 1 )    // -3 clamps up to 1

Ranges are not just numbers. Characters and dates work too:

expect( chars ).toBeAscending()
expect( chars ).toContainValue( "m" )
expect( dates ).toContainValue( "2024-01-15" )

A realistic pagination spec becomes almost self-documenting:

describe( "Pagination window", () => {

    it( "clamps a requested page into the available range", () => {
        var pages = 1..totalPages

        expect( pages ).toBeBounded()
        expect( pages ).toBeAscending()
        expect( pages ).toContainValue( currentPage )
        expect( pages ).toClampTo( 9999, totalPages )
    } )

} )

Data Navigator: Assert Deep Into Payloads Without the Ceremony

What are data navigators? Data Navigators provide a fluent, safe way to navigate and extract data from complex data structures in BoxLang

// ❌ Traditional: Verbose null checking
if ( structKeyExists( config, "database" ) ) {
    if ( structKeyExists( config.database, "connection" ) ) {
        if ( structKeyExists( config.database.connection, "pool" ) ) {
            maxSize = config.database.connection.pool.maxSize ?: 10;
        } else {
            maxSize = 10;
        }
    } else {
        maxSize = 10;
    }
} else {
    maxSize = 10;
}

// ✅ With Navigator: Clean and safe
maxSize = dataNavigate( config ).get( ["database", "connection", "pool", "maxSize"], 10 )

Read more about them here: https://boxlang.ortusbooks.com/boxlang-language/syntax/data-navigators. This is the one that will change how you test APIs.

Testing a nested response used to mean a ladder of intermediate variables and existence guards:

// The old way
expect( response ).toHaveKey( "data" )
expect( response.data ).toHaveKey( "users" )
expect( response.data.users ).toBeArray()
expect( response.data.users.len() ).toBeGT( 0 )
expect( response.data.users[ 1 ] ).toHaveKey( "email" )
expect( response.data.users[ 1 ].email ).toBe( "luis@ortussolutions.com" )

Six lines to assert one thing, and every one of them a place for the test to fail with a message that does not tell you which level broke.

TestBox 7.1 builds on BoxLang's dataNavigate() BIF and collapses that to a path:

expect( response ).toHavePathValue( "data.users[1].email", "luis@ortussolutions.com" )

Paths support dot-notation, array indexes, wildcards, filters and recursive descent. Six new APIs:

var data = {
    "app"   : { "name" : "TestApp", "settings" : { "debug" : true, "port" : 8080 } },
    "users" : [ { "name" : "Alice", "age" : 30 } ]
}

// Existence
expect( data ).toHavePath( "app.settings.debug" )
expect( data ).notToHavePath( "app.nonexistent" )

// Value
expect( data ).toHavePathValue( "app.settings.port", 8080 )

// Type, with friendly aliases
expect( data ).toHavePathType( "app.settings", "struct" )
expect( data ).toHavePathType( "app.settings.port", "num" )

// Arbitrary predicates
expect( data ).toHavePathSatisfying( "app.settings.port", port -> port > 1000 )

And two chaining helpers that hand you back a normal Expectation, so every matcher in TestBox works at the end of a path:

// path() -> the single value at that path
expect( data ).path( "app.name" ).toBe( "TestApp" )
expect( data ).path( "app.settings.port" ).toBeGT( 8000 )
expect( data ).path( "users" ).toHaveLength( 1 )

// queryPath() -> an array of every match, for wildcards and recursive descent
expect( data ).queryPath( "users[*].name" ).toInclude( "Alice" )
expect( data ).queryPath( "nonexistent" ).toBeEmpty()

Put together, an API contract test reads like the contract:

it( "returns a well-formed user payload", () => {
    var response = api.get( "/users" )

    expect( response ).toHavePathValue( "status", 200 )
    expect( response ).toHavePathType( "data.users", "array" )

    // every user has an email, nobody leaks a password
    expect( response ).queryPath( "data.users[*].email" ).notToBeEmpty()
    expect( response ).notToHavePath( "data.users[*].password" )

    // pagination is sane
    expect( response ).toHavePathSatisfying( "meta.total", t -> t >= 0 )
    expect( response ).path( "meta.page" ).toBeGTE( 1 )
} )

That notToHavePath( "data.users[*].password" ) line is worth a second look. One assertion, every user in the array, and a security regression that would otherwise ship silently.


Everyone Gets These

The remaining 18 additions work on BoxLang and CFML alike.

Grouped Assertions: See Every Failure at Once

A failing assertion aborts the test. So you fix one, re-run, discover the next, fix it, re-run. Three failures means three round trips.

$assert.all() runs a set of assertion closures and reports every failure in one pass:

$assert.all( [
    () => $assert.isEqual( "Luis", user.getName() ),
    () => $assert.isEqual( "luis@ortussolutions.com", user.getEmail() ),
    () => $assert.isTrue( user.isActive() )
], "user profile" )

If the name and the active flag are both wrong, you see both:

user profile: 1 of 3 assertions passed
  [1] expected [Luis] but received [Alice]
  [3] expected [true] but received [false]

assertAll() is the spec-level shortcut for the same thing.

assertAll( [
    () =>{ return $assert.isEqual( 200, response.status ); },
    () => { return $assert.key( response, "data" ); }
], "response envelope" );

Collection Expectation Modes

expectAll() has been around a while. It now has three siblings, so the mode carries the meaning instead of a hand-rolled loop:

// every user must have an id
expectAll( users ).toHaveKey( "id" )

// at least one order clears the free-shipping threshold
expectAny( orders ).toBeGT( 50 )

// between 2 and 5 of them are flagged
expectSome( flags, 2, 5 ).toBeTrue()

// no serialized user may carry a password
expectNone( serializedUsers ).toHaveKey( "password" )

Failure messages now carry pass and fail counts plus the index or key of each failing element, so you learn which elements broke rather than just that something did:

expectAll: 3 of 5 elements passed
  [2] expected [null] to have key [id]
  [5] expected [null] to have key [id]

Expectation Context

When several expectations in a spec assert against similar values, expected [100] to be [108] does not tell you which one broke. withContext() fixes that:

expect( order.getSubtotal() ).withContext( "subtotal" ).toBe( 100 )
expect( order.getTotal() ).withContext( "total after tax" ).toBe( 108 )

// Failure: total after tax: expected [100] to be [108]

It flows through standard matchers, negated matchers and your own custom matchers. It is at its best inside loops and data-driven specs, where the same expectation runs many times and the raw message cannot identify the iteration.

Eight New Matchers

// Truthiness, for when the value is "something or nothing" rather than a strict boolean
expect( "hello" ).toBeTruthy()
expect( [] ).toBeFalsy()

// Identity, not equality: is this the same object in memory?
expect( getInstance( "UserService" ) ).toBeSameInstanceAs( cachedService )

// Size across arrays, structs, strings and queries
expect( [ 1, 2, 3 ] ).toHaveSize( 3 )

// Exceptions richer than type-and-message matching
expect( () => paymentService.charge( amount = -5 ) )
    .toThrowMatching( e => e.type == "InvalidAmount" && e.detail contains "negative" )

// Multiple needles at once
expect( roles ).toIncludeAll( [ "admin", "editor" ] )
expect( roles ).toIncludeAny( [ "admin", "superuser" ] )
expect( serializedUser ).toIncludeNone( [ "password", "salt", "apiToken" ] )

toIncludeNone() deserves a callout. Asserting the absence of things that must never leak reads far better than chaining negated toInclude() calls, and it is the kind of test that quietly earns its keep for years.

Five New Assertion BIFs

xUnit style stays at parity with BDD:

$assert.isTruthy( user.isActive() )
$assert.isFalsy( "" )
$assert.includesAll( roles, [ "admin", "editor" ] )
$assert.includesAny( roles, [ "admin", "superuser" ] )
$assert.includesNone( serializedUser, [ "password", "salt", "apiToken" ] )

Skip an Entire Class

BDD test classes now take a class-level skip annotation, so you can park a whole bundle without prefixing every describe() or editing runner filters:

/**
 * @skip Waiting on the sandbox credentials
 */
class extends="testbox.system.BaseSpec"{

    function run(){
        describe( "Payment gateway", () => {
            // none of this runs while @skip is present
        } )
    }

}

Skipped classes are reported as skipped rather than silently dropped, so your totals stay honest and the class does not quietly rot.


Fixes Worth Knowing About

Code coverage is now opt-in

coverageEnabled in the CFML test runner now defaults to false instead of true. Coverage requires FusionReactor, so defaulting it on meant every runner hit paid for a feature most runs did not want.

If you rely on coverage, turn it on explicitly:

/tests/runner.cfm?coverageEnabled=true

This is a behavioral change. If you were leaning on the implicit default, you need to act.

MockBox $args() matching is deterministic again

$args() hashed nested structures in a way that depended on struct iteration order, so two structurally identical structs built in a different order could fail to match and the mock would return null. Always latent, but Lucee 7.1's new map implementation made it reproducible.

It now normalizes deterministically:

mockService.$( "charge" )
    .$args( { amount : 100, currency : "USD" } )
    .$results( true )

// matches, despite the different key order at the call site
mockService.charge( { currency : "USD", amount : 100 } )

$args() also understands BoxLang Set and Range objects now.

isLucee() no longer returns true on BoxLang

BoxLang registers a lucee server scope key for compatibility, so isLucee() was returning true under BoxLang. Any spec branching or skipping on engine took the Lucee path when running on BoxLang. Fixed.

And the rest

  • Date and date/time objects are compared by instant rather than a blind actual.equals()
  • Bundle and spec names are HTML-encoded in the Simple reporter
  • Full null support is handled across the runners, coverage service and mock generator, and is now covered in CI
  • Three BoxLang CLI runner fixes: the runner no longer misreads its own script path as a bundle argument, KeyNotFoundException [url] no longer crashes every CLI run on BoxLang 1.17+, and GetPageContextResponse() works in Adobe compatibility mode

Upgrading

box install testbox@7.1.0

7.1.0 is a minor release and is backward compatible. The one thing to check is coverageEnabled, which now defaults to false.

Add Your Comment

Recent Entries

BoxLang 1.17 Series Part 4 : WriteDump Enhanced!

BoxLang 1.17 Series Part 4 : WriteDump Enhanced!

You call writeDump() on an ORM entity or a very rich class graph . The browser locks up. Thirty seconds later you get a page with forty thousand rows on it, you scroll for a while, you give up, and you go edit your code to dump a sub-key instead. Or worse, you crash the server.

Luis Majano
Luis Majano
September 11, 2026
BoxLang AI 3.4 Blog Series Part 4 : Locking Down Prompt Injection

BoxLang AI 3.4 Blog Series Part 4 : Locking Down Prompt Injection

LLM applications face a class of attack traditional input validation was never built for: prompt injection. An attacker embeds instructions in user input, a retrieved document, a web page your tool fetched, or an MCP result, trying to override your system prompt, exfiltrate data, or hijack a tool call. BoxLang AI 3.4.0 ships four layered, configurable defenses against exactly this, plus one more that's on unconditionally.

Luis Majano
Luis Majano
September 11, 2026
BoxLang 1.17 Series Part 3 : Encrypted Config Secrets

BoxLang 1.17 Series Part 3 : Encrypted Config Secrets

Everyone knows the datasource password should not be sitting in plain text in a config file. Everyone has also, at some point, shipped exactly that, because the alternative was a pile of environment variable plumbing that nobody wanted to build on a deadline. x

Luis Majano
Luis Majano
September 08, 2026