Assertions
Verify your step's response, inter-service traffic, console logs, database queries, and broker messages.
Assertion paths query the root context document — a JSON object assembled after each step executes. If you haven't seen it yet, read The root context first.
How paths work
All paths start with $. and navigate the root context using dot notation, array indices, and nested fields:
$.response.status // top-level field
$.response.body.user.name // nested object access
$.response.body.items[0].id // array index (zero-based)
$.response.body.items[-1] // negative index (last element)
$.response.headers.x-auth // header lookup (case-insensitive)Key lookups are case-insensitive — $.response.headers.Content-Type and $.response.headers.content-type both work.
Writing assertions
Each step can have an assertions array. Place individual assertions directly in the array:
{
"assertions": [
{ "path": "$.response.status", "operator": "eq", "value": 201 },
{ "path": "$.response.body.id", "operator": "exists" },
{ "path": "$.response.body.name", "operator": "eq", "value": "{{userName}}" },
{ "path": "$.response.headers.content-type", "operator": "contains", "value": "application/json" },
{ "path": "$.responseTime", "operator": "lt", "value": 500 }
]
}Each individual assertion has three parts:
| Field | Required | Description |
|---|---|---|
| Source field | Yes | Which value to check. Usually path, but can also be count, type, keys, values, or entries (see Source fields) |
operator | Yes | How to compare (see Operators) |
value | Depends | Expected value. Not needed for exists, notExists, isEmpty, notEmpty |
Assertion blocks
When you need to query captured data (match), extract variables, or loop, wrap assertions in an assertion block — an object with its own assertions array. The block scopes $.match.* results and extract targets to that group. It can include:
| Field | Type | Description |
|---|---|---|
assertions | array | Individual assertions to evaluate |
match | object | Query captured data (traffic, logs, messages) and make results available via $.match.* |
extract | object | Extract values into variables for use in later steps |
forEach / for / repeat | object | Loop the block's assertions (see Loops) |
A step's assertions array can mix flat assertions and blocks freely:
{
"assertions": [
{ "path": "$.response.status", "operator": "eq", "value": 200 },
{
"match": { "path": "$.traffic", "where": ["..."] },
"assertions": [
{ "path": "$.match.response.status", "operator": "eq", "value": 201 }
]
},
{
"assertions": [
{ "path": "$.response.body.total", "operator": "gte", "value": 1 }
],
"extract": { "total": "$.response.body.total" }
}
]
}Match blocks
A match field queries one of the captured data sources on the root context, filters entries, and makes the results available for assertions. This is how you assert on inter-service traffic, console output, database queries, and broker messages.
{
"match": {
"path": "$.traffic",
"where": [
{ "path": "$$.origin", "operator": "eq", "value": "api-gateway" },
{ "path": "$$.request.method", "operator": "eq", "value": "POST" },
{ "path": "$$.request.url", "operator": "contains", "value": "/api/users" }
],
"count": 1
},
"assertions": [
{ "path": "$.match.request.body.email", "operator": "eq", "value": "{{email}}" },
{ "path": "$.match.response.status", "operator": "eq", "value": 201 }
]
}How it works
match.pathselects the data source array (e.g.$.traffic)match.wherefilters that array — each clause uses$$.-prefixed paths that reference fields on the candidate entry, not the root context. All clauses must pass (AND logic).- The matched entries are injected into the root context as:
$.match— the first matched entry$.lastMatch— the last matched entry$.matches— the full array of matched entries
- The block's
assertionscan then reference$.match.*to check the matched data.
Match fields
| Field | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Data source: $.traffic, $.consoleLogs, $.dbLogs, or $.messageLogs |
where | array | No | Filter entries using $$.-prefixed paths. All clauses must pass (AND). Omit to match all entries. |
count | integer or object | No | Assert how many entries match. Integer for exact count, or { "operator": "gte", "value": 1 } for range checks (eq, gt, gte, lt, lte). Omit to require at least one. |
as | string | No | Save matched entries to a named variable, accessible as $.variables.<name> or {{name}} in later steps |
Where clauses
Each where clause uses $$.-prefixed paths to reference the candidate entry being tested. The $$. prefix means "this entry" — it scopes the path to the array element under evaluation rather than the root context.
"where": [
{ "path": "$$.origin", "operator": "eq", "value": "api-gateway" },
{ "path": "$$.request.method", "operator": "eq", "value": "POST" },
{ "path": "$$.request.url", "operator": "contains", "value": "/api/users" }
]Use combinators for complex filtering:
"where": [
{ "or": [
{ "path": "$$.request.method", "operator": "eq", "value": "POST" },
{ "path": "$$.request.method", "operator": "eq", "value": "PUT" }
]},
{ "not": { "path": "$$.request.url", "operator": "contains", "value": "/health" } }
]Where clause values also support {{variables}} and ValueRef ({ "from": "$.path" }) to compare against root context values.
Iterating over multiple matches
To assert on each matched entry individually, use forEach with $.matches. The loop variable is accessed via $.variables.<as>:
{
"match": {
"path": "$.traffic",
"where": [
{ "path": "$$.origin", "operator": "eq", "value": "api-gateway" }
]
},
"forEach": {
"items": "$.matches",
"as": "entry",
"assertions": [
{ "path": "$.variables.entry.response.status", "operator": "eq", "value": 200 }
]
}
}Data sources
Each data source is an array on the root context. Use match to filter entries and assert on them.
Scoped per test. $.traffic, $.consoleLogs, $.dbLogs, and $.messageLogs only include entries captured during the current test's steps. Traffic from one test is not visible in another.
HTTP traffic ($.traffic)
HTTP requests captured between services by the interceptor sidecar. Each entry contains the full request and response:
{
"match": {
"path": "$.traffic",
"where": [
{ "path": "$$.origin", "operator": "eq", "value": "api-gateway" },
{ "path": "$$.request.url", "operator": "contains", "value": "/api/users" }
],
"count": 1
},
"assertions": [
{ "path": "$.match.response.status", "operator": "eq", "value": 201 },
{ "path": "$.match.response.body.id", "operator": "exists" }
]
}Traffic entry fields
| Path | Type | Description |
|---|---|---|
$$.origin / $$.from | string | Service that sent the request |
$$.to | string | Destination service |
$$.request.method | string | HTTP method |
$$.request.url | string | Request URL |
$$.request.headers | object | Request headers (lowercase keys) |
$$.request.body | any | Request body |
$$.response.status | number | HTTP status code |
$$.response.headers | object | Response headers (lowercase keys) |
$$.response.body | any | Response body |
$$.responseTime | number | Response time in milliseconds |
$$.timestamp | string | When the request was captured |
Console logs ($.consoleLogs)
Console output from services, captured by the interceptor sidecar:
{
"match": {
"path": "$.consoleLogs",
"where": [
{ "path": "$$.service", "operator": "eq", "value": "user-service" },
{ "path": "$$.level", "operator": "eq", "value": "INFO" },
{ "path": "$$.message", "operator": "contains", "value": "User created" }
],
"count": 1
}
}Assert zero errors from a service:
{
"match": {
"path": "$.consoleLogs",
"where": [
{ "path": "$$.service", "operator": "eq", "value": "user-service" },
{ "path": "$$.level", "operator": "eq", "value": "ERROR" }
],
"count": 0
}
}Match with regex:
{
"match": {
"path": "$.consoleLogs",
"where": [
{ "path": "$$.service", "operator": "eq", "value": "user-service" },
{ "path": "$$.message", "operator": "matches", "value": "Processing order \\d+" }
],
"count": 1
}
}Console log entry fields
| Path | Type | Description |
|---|---|---|
$$.service | string | Service name that emitted the log |
$$.level | enum | INFO, WARN, ERROR, DEBUG |
$$.message | string | Log message content |
$$.timestamp | number | Unix timestamp |
Database logs ($.dbLogs)
Database queries captured by the DB proxy sidecar. Use this to verify that a service executed the expected queries:
{
"match": {
"path": "$.dbLogs",
"where": [
{ "path": "$$.database", "operator": "eq", "value": "users-db" },
{ "path": "$$.query", "operator": "contains", "value": "INSERT INTO users" }
],
"count": 1
},
"assertions": [
{ "path": "$.match.result.success", "operator": "eq", "value": true }
]
}Database log entry fields
| Path | Type | Description |
|---|---|---|
$$.database | string | Database item name |
$$.query | string | SQL query or command |
$$.duration | number | Query execution time in milliseconds |
$$.result.success | boolean | Whether the query succeeded |
$$.result.rowsAffected | number | Number of affected rows |
$$.result.error | string | Error message if query failed |
$$.timestamp | string | When the query was captured |
Broker messages ($.messageLogs)
Messages captured by the broker proxy sidecar (AMQP or Kafka):
{
"match": {
"path": "$.messageLogs",
"where": [
{ "path": "$$.operation", "operator": "eq", "value": "produce" },
{ "path": "$$.brokerType", "operator": "eq", "value": "kafka" },
{ "path": "$$.topic", "operator": "eq", "value": "order-events" }
],
"count": 1
},
"assertions": [
{ "path": "$.match.body.orderId", "operator": "eq", "value": 42 }
]
}Message log entry fields
| Path | Type | Description |
|---|---|---|
$$.broker | string | Broker item name |
$$.brokerType | enum | "amqp" or "kafka" |
$$.operation | enum | "publish" / "deliver" (AMQP) or "produce" / "consume" (Kafka) |
$$.body | any | Message body (parsed JSON or raw string) |
$$.exchange | string | AMQP exchange name |
$$.routingKey | string | AMQP routing key |
$$.topic | string | Kafka topic |
$$.partition | integer | Kafka partition index |
$$.key | string/object | Kafka message key |
$$.offset | integer | Kafka message offset (consume only) |
$$.timestamp | string | When the message was captured |
Path reference
Step response — HTTP
| Path | Description |
|---|---|
$.response.status | HTTP status code (integer) |
$.response.body | Entire response body |
$.response.body.field | Top-level field in response body |
$.response.body.nested.field | Nested field (dot notation) |
$.response.body[0].field | Array element access |
$.response.headers.header-name | Response header (case-insensitive) |
$.request.method | HTTP method of the request |
$.request.body | Entire request body |
$.request.body.field | Field in request body |
$.request.headers.header-name | Request header (case-insensitive) |
$.responseTime | Response time in milliseconds |
Step response — Database query
| Path | Description |
|---|---|
$.response.success | Boolean — did the query succeed? |
$.response.data | Array of result rows |
$.response.data[0].column | Specific column from a result row |
$.response.rowsAffected | Number of affected rows (integer) |
$.response.error | Error message if query failed |
Variables
| Path | Description |
|---|---|
$.variables.<name> | A variable's value (equivalent to {{name}} in most contexts) |
$.variables.<name>.field | Nested access into an object variable |
$.variables.<name>[0] | Array element from a variable |
Match results
Available inside assertion blocks that have a match field:
| Path | Description |
|---|---|
$.match.* | First matched entry |
$.lastMatch.* | Last matched entry |
$.matches | Full array of matched entries |
$.matches[N].* | Specific matched entry by index |
Operators
| Operator | Value required? | Value type | Description |
|---|---|---|---|
eq | Yes | any | Equality with coercion: numeric (1 == "1") and boolean (true == "TRUE"); case-sensitive for non-boolean strings |
eqIgnoreCase | Yes | any | Equality, case-insensitive for strings |
ne | Yes | any | Not equal (inverse of eq) |
gt | Yes | number | Greater than |
gte | Yes | number | Greater than or equal |
lt | Yes | number | Less than |
lte | Yes | number | Less than or equal |
contains | Yes | any | Substring match for strings, element check for arrays, key check for objects (case-sensitive) |
notContains | Yes | any | Inverse of contains (case-sensitive) |
containsIgnoreCase | Yes | string | Substring match (case-insensitive) |
notContainsIgnoreCase | Yes | string | Substring does NOT match (case-insensitive) |
matches | Yes | string (regex) | Regular expression match |
exists | No | — | Value exists (defined and not null) |
notExists | No | — | Value does not exist |
in | Yes | array | Value is in the given array |
notIn | Yes | array | Value is NOT in the given array |
isEmpty | No | — | Value is empty/null/undefined/empty array/empty object |
notEmpty | No | — | Value is not empty |
Type coercion
The eq and ne operators use loose comparison with automatic type coercion.
How eq compares values
eq checks three things in order:
- Exact match — identical type and value (e.g.
42 == 42,"hello" == "hello") - Boolean coercion — if both sides can be interpreted as booleans, compare as booleans. The string
"true"(any casing) coerces totrue,"false"tofalse - Numeric coercion — if both sides can be parsed as numbers, compare as numbers. The string
"42"coerces to42,"1.5"to1.5
ne is the inverse of eq — it uses the same coercion rules.
What passes
| Actual | Expected | Result | Why |
|---|---|---|---|
42 | "42" | pass | Both parse as numbers |
"1.0" | 1 | pass | Both parse as numbers |
true | "true" | pass | Both coerce to boolean true |
"FALSE" | false | pass | Case-insensitive boolean coercion |
[1, 2] | [1, 2] | pass | Deep equality |
What fails (and may surprise you)
| Actual | Expected | Result | Why |
|---|---|---|---|
"Hello" | "hello" | fail | eq is case-sensitive for strings. Use eqIgnoreCase |
" 42 " | 42 | fail | Leading/trailing whitespace prevents numeric parsing |
null | "" | fail | null is not coerced to empty string. Use exists/notExists for null checks |
"0" | false | fail | Numbers are not coerced to booleans — only "true"/"false" strings match booleans |
0 | false | fail | Same reason — no number-to-boolean bridge |
ne inherits coercion. Because ne is !eq, coercion works in reverse: "42" ne 42 fails (they're considered equal). If you need to distinguish the string "42" from the number 42, use the type source field instead.
Numeric operators (gt, gte, lt, lte)
These always coerce both sides to numbers. If either side can't be parsed as a number, the assertion errors (not just fails). Numeric strings like "200" are coerced, but "fast" or true will error.
String operators (contains, matches)
Both sides are converted to their string representation before comparison. Non-string values are stringified: numbers become "42", booleans become "true"/"false". For objects and arrays, the stringified form may not match JSON — use string operators on string values for reliable results.
Tip: If you're unsure about a value's type, use the type source field to assert it explicitly before comparing: { "type": "$.response.body.count", "operator": "eq", "value": "number" }
Source fields
Source fields are alternatives to path that transform the resolved value before the operator is applied. Each assertion must have exactly one source field.
| Source field | Resolves to | Description |
|---|---|---|
path | any | The raw value at the given path — the default and most common source field |
type | string | The type of the value at the path: "string", "number", "boolean", "object", "array", or "null" |
count | number | Length of an array or string at the path |
keys | array | Sorted array of key strings from an object at the path |
values | array | Array of values from an object at the path (sorted by key) |
entries | array | Array of { key, value } objects from an object at the path (sorted by key) |
Examples:
// Check the type of a value
{ "type": "$.response.body.items", "operator": "eq", "value": "array" }
// Check the length of an array
{ "count": "$.response.body.items", "operator": "eq", "value": 3 }
// Check if an object has a specific key
{ "keys": "$.response.body.config", "operator": "contains", "value": "timeout" }ValueRef
Instead of a literal value, you can compare against another path in the root context using { "from": "$.path" }:
{ "path": "$.response.body.createdAt", "operator": "eq", "value": { "from": "$.response.body.updatedAt" } }This resolves both sides before comparing, enabling dynamic assertions that compare two fields against each other.