*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:
| Method | What 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:
| Syntax | Meaning | Example |
|---|---|---|
| dot notation | walk into nested keys | app.settings.port |
| array index | pick one element, 1-based | users[1] |
wildcard * | every value in a struct or every element of an array | users[*].name |
slice [n:m] | 1-based inclusive range of elements | users[1:3] |
| open slice | from an index to the end | users[2:] |
filter [?()] | keep elements that match a condition, @ is the current element | users[?(@.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:
- Arrays are 1-based.
users[1]is the first user, not the second. 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 wantquery().
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.
| API | What 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 neededqueryPath(). If the path contains*, a filter, a slice or.., you are asking for many values, so usequeryPath(). - Expecting a missing path to throw. It does not. That is the point of a navigator. Assert absence with
notToHavePath(),path( ... ).toBeNull()orqueryPath( ... ).toBeEmpty(). - Running on CFML. These APIs throw
TestBox.BoxLangFeatureNotAvailablethere.
Cheat sheet
| You want to assert | Old way | New way |
|---|---|---|
| a nested key exists | chained toHaveKey() calls | toHavePath( "a.b.c" ) |
| a nested value | chained checks plus an equality check | toHavePathValue( "a.b.c", value ) |
| a nested type | manual isStruct(), isArray() and so on | toHavePathType( "a.b", "struct" ) |
| a rule about a value | pull it out, then assert | toHavePathSatisfying( "a.b", v -> v > 0 ) |
| any matcher on a nested value | intermediate variable | path( "a.b" ).toBeGT( 0 ) |
| something about every element | a loop | queryPath( "list[*].name" ).toInclude( "x" ) |
| something is absent everywhere | a recursive helper | notToHavePath( "..secret" ) |
Next up, Part 4: grouped assertions and collection modes, so one failing check no longer hides the other four.
Add Your Comment