Blog

Luis Majano

September 29, 2026

Spread the word


Share your thoughts

*Part 3 of 5 in our series on TestBox 7.1's 47 new assertions. Read the release announcement.

Almost every application eventually tests a nested piece of data: an API response, a JSON config file, a serialized object, module metadata. The data is a struct that contains an array that contains structs that contain more structs, and the thing you actually care about is one value buried at the bottom.

This post has two halves. First we look at BoxLang's Data Navigator, the language feature that makes nested data pleasant to work with. Then we look at the six TestBox 7.1 expectations built on top of it. If you have never used dataNavigate(), start at the top. If you already know it, skip to The six expectations.

The problem: asserting on nested data

Suppose your API returns this:

{
    "status" : 200,
    "data"   : {
        "users" : [
            { "id" : 1, "name" : "Alice", "email" : "alice@example.com" }
        ]
    }
}

To assert that the first user's email is correct, the traditional approach looks like this:

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( "alice@example.com" )

Six lines to check one value. Each line exists only because the next line would blow up with a confusing "key not found" error if the previous level was missing. You are writing defensive code inside your test, and the test is supposed to be the simple part.

BoxLang Data Navigators, from scratch

A Data Navigator is a small helper object that wraps a piece of data and lets you move through it safely. Instead of writing data.users[1].email and hoping every level exists, you ask the navigator for the value and it tells you what it found, without throwing when something is missing.

You create one with the dataNavigate() built-in function. It accepts several kinds of input and figures out which one you gave it:

// From a struct
nav = dataNavigate( { "app" : { "name" : "MyApp", "port" : 8080 } } )

// From a JSON string
nav = dataNavigate( '{"users":[{"name":"Alice"},{"name":"Bob"}]}' )

// From a path to a JSON file
nav = dataNavigate( "/path/to/config.json" )

Once you have a navigator, the core methods are:

MethodWhat it does
get( path, [default] )returns the value at the path, or the default (or null) if it is missing
has( path )returns true or false, never throws
from( path )returns a new navigator scoped to that part of the data
query( path )returns an array of every value that matches the path
getOrDefault( path, default )like get(), but guarantees a non-null result
getOrThrow( path )throws if the value is missing, for required settings

Navigators are immutable. Every call that moves you somewhere returns a new navigator, so you can safely share one across threads.

Path expressions

Since BoxLang 1.14.0, the path you give a navigator can be a compact string expression, modeled on JSONPath. This is the part TestBox builds on, so it is worth learning properly:

SyntaxMeaningExample
dot notationwalk into nested keysapp.settings.port
array indexpick one element, 1-basedusers[1]
wildcard *every value in a struct or every element of an arrayusers[*].name
slice [n:m]1-based inclusive range of elementsusers[1:3]
open slicefrom an index to the endusers[2:]
filter [?()]keep elements that match a condition, @ is the current elementusers[?(@.active == true)]
recursive descent ..find a key at any depth..email

Filters support ==, !=, >, <, >=, <=, combine with && and ||, negate with !, and can test that a field exists and is truthy:

nav.query( "users[?(@.active == true && @.age > 18)]" )   // both conditions
nav.query( "users[?(!@.active)]" )                          // inactive users
nav.query( "users[?(@.email)].name" )                       // names of users that have an email

Two rules trip up newcomers, so keep them in mind for the rest of this post:

  1. Arrays are 1-based. users[1] is the first user, not the second.
  2. get() returns one thing, query() returns an array of everything. If your path fans out across a collection (*, a filter, a slice, ..), you almost always want query().

Here is the six-line ladder from earlier, replaced with a navigator:

email = dataNavigate( response ).get( "data.users[1].email" )

That is the whole idea. The full reference, including typed getters like getAsInteger(), ifPresent(), and exact-key lookups for keys that contain dots, lives in the Data Navigators documentation. The path expression additions are covered in the BoxLang 1.14.0 release notes.

The six expectations

TestBox 7.1 takes dataNavigate() and wraps it in expectations, so you get the same paths and the same safety inside your specs. You write expect( data ) as usual and hand the matcher a path.

APIWhat it asserts
toHavePath( path )the path exists
toHavePathValue( path, expected )the value at the path equals expected
toHavePathType( path, type )the value at the path is of the given type
toHavePathSatisfying( path, predicate )the value at the path passes your closure
path( path )returns a normal expectation on the single value at the path
queryPath( path )returns a normal expectation on an array of all matches

The first four have negated forms: notToHavePath(), notToHavePathValue(), notToHavePathType(), notToHavePathSatisfying().

Let's go through each with the same small data set:

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

toHavePath: does it exist?

expect( data ).toHavePath( "app.name" )
expect( data ).toHavePath( "users[1].name" )
expect( data ).notToHavePath( "app.nonexistent" )

This replaces the toHaveKey() ladder. It does not matter how deep the path goes, and a missing middle segment simply means "no such path" instead of an error.

toHavePathValue: is the value right?

expect( data ).toHavePathValue( "app.settings.port", 8080 )
expect( data ).toHavePathValue( "app.settings.debug", true )
expect( data ).notToHavePathValue( "app.name", "WrongApp" )

This is the workhorse. One line replaces the whole ladder from the start of this post.

toHavePathType: is it the right kind of thing?

expect( data ).toHavePathType( "app.name", "string" )
expect( data ).toHavePathType( "app.settings", "struct" )
expect( data ).toHavePathType( "users", "array" )
expect( data ).toHavePathType( "app.settings.port", "num" )   // alias

Supported types are string, numeric, boolean, struct and array, plus short aliases: str, num, bool, arr, obj and map. Type checks are how you catch the classic API regression where a number quietly becomes a string.

toHavePathSatisfying: when equality is not enough

Sometimes you do not know the exact value, only a rule it must follow. Pass a closure and return true or false:

expect( data ).toHavePathSatisfying( "app.settings.port", port -> port > 1000 )
expect( data ).toHavePathSatisfying( "app.settings", settings -> settings.keyExists( "debug" ) )

path: hand the value to any matcher

path() navigates to a value and gives you back an ordinary expectation on it. That means every matcher TestBox has works at the end of a path, including the new ones from earlier posts:

expect( data ).path( "app.name" ).toBe( "TestApp" )
expect( data ).path( "app.settings.port" ).toBeGT( 8000 )
expect( data ).path( "users" ).toHaveLength( 1 )
expect( data ).path( "nonexistent" ).toBeNull()

queryPath: assert on many values at once

queryPath() is path() for paths that match more than one thing. It returns an expectation on an array of every match:

expect( data ).queryPath( "users[*].name" ).toHaveLength( 1 )
expect( data ).queryPath( "users[*].name" ).toInclude( "Alice" )
expect( data ).queryPath( "nonexistent" ).toBeEmpty()

Use path() when you expect one value. Use queryPath() when the path contains *, a filter, a slice or ... A path that matches nothing gives you an empty array, so toBeEmpty() is how you assert "nothing matched".

Hands-on: testing a users API

Time to put it together. We will build a small service that returns a paginated API response, then write a spec that checks its contract. Both files below are complete. You can copy, paste and run them.

Setting up

You need BoxLang, CommandBox and TestBox 7.1.0 or newer. From a fresh folder:

box install testbox@7.1.0
box install testbox-cli
testbox generate harness

testbox generate harness creates a tests folder with a runner and a specs folder. Save both files below in the same folder (for example tests/specs), because the spec creates the service with new UserApiService() and BoxLang resolves that relative to the spec.

The system under test

/**
 * UserApiService.bx
 *
 * Pretends to be the layer that turns database rows into an API response.
 * The internal rows carry sensitive fields (passwordHash) that must NEVER
 * appear in a response. That rule is exactly what our spec will guard.
 */
class {

	// Pretend database rows
	variables.rows = [
		{ id : 1, name : "Alice",   email : "alice@example.com",   role : "admin",  active : true,  passwordHash : "h1", profile : { country : "US", plan : "pro"  } },
		{ id : 2, name : "Bob",     email : "bob@example.com",     role : "editor", active : true,  passwordHash : "h2", profile : { country : "SV", plan : "free" } },
		{ id : 3, name : "Charlie", email : "charlie@example.com", role : "viewer", active : false, passwordHash : "h3", profile : { country : "ES", plan : "free" } },
		{ id : 4, name : "Dana",    email : "dana@example.com",    role : "editor", active : true,  passwordHash : "h4", profile : { country : "US", plan : "pro"  } },
		{ id : 5, name : "Eli",     email : "eli@example.com",     role : "viewer", active : false, passwordHash : "h5", profile : { country : "SV", plan : "free" } }
	];

	/**
	 * Returns one page of users wrapped in an API envelope.
	 * An out of range page returns a 400 error envelope instead.
	 */
	function getUsers( numeric page = 1, numeric perPage = 2 ){
		var total      = variables.rows.len();
		var totalPages = ceiling( total / arguments.perPage );

		if ( arguments.page < 1 || arguments.page > totalPages ) {
			return {
				"status" : 400,
				"error"  : {
					"code"    : "INVALID_PAGE",
					"message" : "Page must be between 1 and #totalPages#"
				}
			};
		}

		var start  = ( ( arguments.page - 1 ) * arguments.perPage ) + 1;
		var length = min( arguments.perPage, total - start + 1 );
		var slice  = variables.rows.slice( start, length );

		return {
			"status" : 200,
			"data"   : { "users" : slice.map( row -> toPublic( row ) ) },
			"meta"   : {
				"page"       : arguments.page,
				"perPage"    : arguments.perPage,
				"total"      : total,
				"totalPages" : totalPages
			}
		};
	}

	/**
	 * Whitelist what leaves the building. Note passwordHash is not copied.
	 */
	private function toPublic( row ){
		return {
			"id"      : row.id,
			"name"    : row.name,
			"email"   : row.email,
			"role"    : row.role,
			"active"  : row.active,
			"profile" : { "country" : row.profile.country, "plan" : row.profile.plan }
		};
	}

}

The spec

/**
 * UserApiServiceSpec.bx
 *
 * Data Navigator expectations: testing an API contract.
 */
class extends="testbox.system.BaseSpec" {

	function run(){

		describe( "UserApiService", () => {

			beforeEach( () => {
				variables.service = new UserApiService();
			} );

			describe( "a successful response", () => {

				beforeEach( () => {
					// all five users on one page, so we can reason about totals
					variables.response = service.getUsers( page = 1, perPage = 5 );
				} );

				it( "has the expected envelope", () => {
					expect( response ).toHavePath( "data.users" );
					expect( response ).toHavePath( "meta.totalPages" );
					expect( response ).notToHavePath( "error" );
					expect( response ).toHavePathValue( "status", 200 );
				} );

				it( "reports sane pagination metadata", () => {
					expect( response ).toHavePathValue( "meta.page", 1 );
					expect( response ).toHavePathValue( "meta.total", 5 );
					expect( response ).toHavePathSatisfying( "meta.totalPages", t -> t >= 1 );
				} );

				it( "returns values of the right type", () => {
					expect( response ).toHavePathType( "data.users", "array" );
					expect( response ).toHavePathType( "meta.total", "numeric" );
					expect( response ).toHavePathType( "meta.total", "num" ); // alias
					expect( response ).toHavePathType( "data.users[1].active", "boolean" );
					expect( response ).toHavePathType( "data.users[1].profile", "struct" );
				} );

				it( "puts the right user in the right slot (arrays are 1-based)", () => {
					expect( response ).toHavePathValue( "data.users[1].name", "Alice" );
					expect( response ).toHavePathValue( "data.users[2].email", "bob@example.com" );
					expect( response ).toHavePathValue( "data.users[1].profile.country", "US" );
				} );

				it( "applies a rule with a predicate when equality is too strict", () => {
					expect( response ).toHavePathSatisfying( "data.users[1].email", e -> e contains "@" );
				} );

				it( "hands a single value to any normal matcher with path()", () => {
					expect( response ).path( "meta.perPage" ).toBe( 5 );
					expect( response ).path( "data.users" ).toHaveLength( 5 );
					expect( response ).path( "data.users[1].id" ).toBeGT( 0 );
					expect( response ).path( "error" ).toBeNull();
				} );

			} );

			describe( "queryPath() across collections", () => {

				beforeEach( () => {
					variables.response = service.getUsers( page = 1, perPage = 5 );
				} );

				it( "wildcards select the same field from every element", () => {
					expect( response ).queryPath( "data.users[*].name" ).toHaveLength( 5 );
					expect( response ).queryPath( "data.users[*].name" ).toInclude( "Dana" );
				} );

				it( "every user has an email", () => {
					expect( response ).queryPath( "data.users[*].email" ).toHaveLength( 5 );
				} );

				it( "slices take a 1-based inclusive range", () => {
					expect( response ).queryPath( "data.users[1:2]" ).toHaveLength( 2 );
				} );

				it( "filters find elements by condition", () => {
					// three active users: Alice, Bob, Dana
					expect( response ).queryPath( "data.users[?(@.active == true)]" ).toHaveLength( 3 );
					expect( response ).queryPath( "data.users[?(@.active == true)].name" ).toInclude( "Dana" );

					// two inactive users, using negation
					expect( response ).queryPath( "data.users[?(!@.active)]" ).toHaveLength( 2 );
				} );

				it( "recursive descent finds a key at any depth", () => {
					// every email in the whole response, wherever it lives
					expect( response ).queryPath( "..email" ).toHaveLength( 5 );
					// the nested profile.country of every user
					expect( response ).queryPath( "..country" ).toHaveLength( 5 );
				} );

			} );

			describe( "security", () => {

				it( "never leaks password hashes anywhere in the response", () => {
					var response = service.getUsers( page = 1, perPage = 5 );

					// specific path check on one user
					expect( response ).notToHavePath( "data.users[1].passwordHash" );

					// and a sweep of the ENTIRE response at any depth
					expect( response ).notToHavePath( "..passwordHash" );
					expect( response ).queryPath( "..passwordHash" ).toBeEmpty();
				} );

			} );

			describe( "an invalid page", () => {

				it( "returns a 400 error envelope and no data", () => {
					var response = service.getUsers( page = 99 );

					expect( response ).toHavePathValue( "status", 400 );
					expect( response ).toHavePathValue( "error.code", "INVALID_PAGE" );
					expect( response ).toHavePathSatisfying( "error.message", m -> m contains "between 1 and" );
					expect( response ).notToHavePath( "data" );
				} );

			} );

		} );

	}

}

Running it

testbox run bundles=tests.specs.UserApiServiceSpec

Adjust the bundles path if you saved the files somewhere other than tests/specs.

What to notice

The tests read like the contract. "The response has a data.users array, meta.total is numeric, the first user is Alice" is nearly a word for word description of the assertions.

The security tests are now cheap to write. Look at the "never leaks password hashes" test. notToHavePath( "..passwordHash" ) sweeps the entire response, at any depth, in one line. Writing that by hand means recursively walking nested structs and arrays. Because it is one line, people actually write it, and it will still catch the leak the day someone adds a passwordHash field to the public response by accident.

You get precise navigation with no defensive code. When the API returns an error envelope, data does not exist. notToHavePath( "data" ) says exactly that, and nothing throws while you check.

path() and queryPath() unlock the whole matcher library. You are not limited to the four toHavePath* matchers. Anything that works with expect() works after path(), so a new matcher you learn next month is instantly usable on nested data.

Common mistakes

  • Using path() where you needed queryPath(). If the path contains *, a filter, a slice or .., you are asking for many values, so use queryPath().
  • Expecting a missing path to throw. It does not. That is the point of a navigator. Assert absence with notToHavePath(), path( ... ).toBeNull() or queryPath( ... ).toBeEmpty().
  • Running on CFML. These APIs throw TestBox.BoxLangFeatureNotAvailable there.

Cheat sheet

You want to assertOld wayNew way
a nested key existschained toHaveKey() callstoHavePath( "a.b.c" )
a nested valuechained checks plus an equality checktoHavePathValue( "a.b.c", value )
a nested typemanual isStruct(), isArray() and so ontoHavePathType( "a.b", "struct" )
a rule about a valuepull it out, then asserttoHavePathSatisfying( "a.b", v -> v > 0 )
any matcher on a nested valueintermediate variablepath( "a.b" ).toBeGT( 0 )
something about every elementa loopqueryPath( "list[*].name" ).toInclude( "x" )
something is absent everywherea recursive helpernotToHavePath( "..secret" )

Next up, Part 4: grouped assertions and collection modes, so one failing check no longer hides the other four.

Resources

Add Your Comment

Recent Entries

try.boxlang.io Now Runs Every BoxLang Version, With or Without CFML

try.boxlang.io Now Runs Every BoxLang Version, With or Without CFML

The BoxLang playground at try.boxlang.io just got a major upgrade. You can now choose any released version of BoxLang and toggle CFML Compat mode on or off, right from the editor toolbar. No installs, no Docker images, no servers. Open a browser, write code, hit Run.

And the best part? The whole thing is powered by BoxLang Serverless on AWS Lambda, built and deployed from a single repository. Keep reading, because if you have ever wondered what BoxLang can do in the cloud, this is your answer. ☁️

Luis Majano
Luis Majano
September 28, 2026
Assert Like You Mean It, TestBox 7.1 Part 2: Range Expectations

Assert Like You Mean It, TestBox 7.1 Part 2: Range Expectations

Before exploring the 14 range matchers, let’s take a closer look at the BoxLang Range itself. Ranges go well beyond the familiar .. syntax, providing a powerful and flexible foundation for working with sequences and boundaries. Understanding how ranges work makes the matcher API much easier to use and reason about. If you are coming from a CFML background, then this will be brand new to you as ranges have never existed until Boxlang.

Luis Majano
Luis Majano
September 24, 2026
ColdBox 8.2.0: Middleware, Streaming, AI Gateways, and a Whole Lot More

ColdBox 8.2.0: Middleware, Streaming, AI Gateways, and a Whole Lot More

ColdBox 8.2.0 is the result of work we started in March. Several of its headline features, including route-scoped middleware and Server-Sent Events, were incubated for months before shipping. We held them back on purpose: we wanted the APIs settled, the edge cases covered, and the testing story complete before asking you to build on them. They're ready now.

Luis Majano
Luis Majano
September 23, 2026