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:

FieldRequiredDescription
Source fieldYesWhich value to check. Usually path, but can also be count, type, keys, values, or entries (see Source fields)
operatorYesHow to compare (see Operators)
valueDependsExpected 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:

FieldTypeDescription
assertionsarrayIndividual assertions to evaluate
matchobjectQuery captured data (traffic, logs, messages) and make results available via $.match.*
extractobjectExtract values into variables for use in later steps
forEach / for / repeatobjectLoop 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

  1. match.path selects the data source array (e.g. $.traffic)
  2. match.where filters that array — each clause uses $$.-prefixed paths that reference fields on the candidate entry, not the root context. All clauses must pass (AND logic).
  3. 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
  4. The block's assertions can then reference $.match.* to check the matched data.

Match fields

FieldTypeRequiredDescription
pathstringYesData source: $.traffic, $.consoleLogs, $.dbLogs, or $.messageLogs
wherearrayNoFilter entries using $$.-prefixed paths. All clauses must pass (AND). Omit to match all entries.
countinteger or objectNoAssert 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.
asstringNoSave 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

PathTypeDescription
$$.origin / $$.fromstringService that sent the request
$$.tostringDestination service
$$.request.methodstringHTTP method
$$.request.urlstringRequest URL
$$.request.headersobjectRequest headers (lowercase keys)
$$.request.bodyanyRequest body
$$.response.statusnumberHTTP status code
$$.response.headersobjectResponse headers (lowercase keys)
$$.response.bodyanyResponse body
$$.responseTimenumberResponse time in milliseconds
$$.timestampstringWhen 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

PathTypeDescription
$$.servicestringService name that emitted the log
$$.levelenumINFO, WARN, ERROR, DEBUG
$$.messagestringLog message content
$$.timestampnumberUnix 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

PathTypeDescription
$$.databasestringDatabase item name
$$.querystringSQL query or command
$$.durationnumberQuery execution time in milliseconds
$$.result.successbooleanWhether the query succeeded
$$.result.rowsAffectednumberNumber of affected rows
$$.result.errorstringError message if query failed
$$.timestampstringWhen 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

PathTypeDescription
$$.brokerstringBroker item name
$$.brokerTypeenum"amqp" or "kafka"
$$.operationenum"publish" / "deliver" (AMQP) or "produce" / "consume" (Kafka)
$$.bodyanyMessage body (parsed JSON or raw string)
$$.exchangestringAMQP exchange name
$$.routingKeystringAMQP routing key
$$.topicstringKafka topic
$$.partitionintegerKafka partition index
$$.keystring/objectKafka message key
$$.offsetintegerKafka message offset (consume only)
$$.timestampstringWhen the message was captured

Path reference

Step response — HTTP

PathDescription
$.response.statusHTTP status code (integer)
$.response.bodyEntire response body
$.response.body.fieldTop-level field in response body
$.response.body.nested.fieldNested field (dot notation)
$.response.body[0].fieldArray element access
$.response.headers.header-nameResponse header (case-insensitive)
$.request.methodHTTP method of the request
$.request.bodyEntire request body
$.request.body.fieldField in request body
$.request.headers.header-nameRequest header (case-insensitive)
$.responseTimeResponse time in milliseconds

Step response — Database query

PathDescription
$.response.successBoolean — did the query succeed?
$.response.dataArray of result rows
$.response.data[0].columnSpecific column from a result row
$.response.rowsAffectedNumber of affected rows (integer)
$.response.errorError message if query failed

Variables

PathDescription
$.variables.<name>A variable's value (equivalent to {{name}} in most contexts)
$.variables.<name>.fieldNested access into an object variable
$.variables.<name>[0]Array element from a variable

Match results

Available inside assertion blocks that have a match field:

PathDescription
$.match.*First matched entry
$.lastMatch.*Last matched entry
$.matchesFull array of matched entries
$.matches[N].*Specific matched entry by index

Operators

OperatorValue required?Value typeDescription
eqYesanyEquality with coercion: numeric (1 == "1") and boolean (true == "TRUE"); case-sensitive for non-boolean strings
eqIgnoreCaseYesanyEquality, case-insensitive for strings
neYesanyNot equal (inverse of eq)
gtYesnumberGreater than
gteYesnumberGreater than or equal
ltYesnumberLess than
lteYesnumberLess than or equal
containsYesanySubstring match for strings, element check for arrays, key check for objects (case-sensitive)
notContainsYesanyInverse of contains (case-sensitive)
containsIgnoreCaseYesstringSubstring match (case-insensitive)
notContainsIgnoreCaseYesstringSubstring does NOT match (case-insensitive)
matchesYesstring (regex)Regular expression match
existsNoValue exists (defined and not null)
notExistsNoValue does not exist
inYesarrayValue is in the given array
notInYesarrayValue is NOT in the given array
isEmptyNoValue is empty/null/undefined/empty array/empty object
notEmptyNoValue 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:

  1. Exact match — identical type and value (e.g. 42 == 42, "hello" == "hello")
  2. Boolean coercion — if both sides can be interpreted as booleans, compare as booleans. The string "true" (any casing) coerces to true, "false" to false
  3. Numeric coercion — if both sides can be parsed as numbers, compare as numbers. The string "42" coerces to 42, "1.5" to 1.5

ne is the inverse of eq — it uses the same coercion rules.

What passes

ActualExpectedResultWhy
42"42"passBoth parse as numbers
"1.0"1passBoth parse as numbers
true"true"passBoth coerce to boolean true
"FALSE"falsepassCase-insensitive boolean coercion
[1, 2][1, 2]passDeep equality

What fails (and may surprise you)

ActualExpectedResultWhy
"Hello""hello"faileq is case-sensitive for strings. Use eqIgnoreCase
" 42 "42failLeading/trailing whitespace prevents numeric parsing
null""failnull is not coerced to empty string. Use exists/notExists for null checks
"0"falsefailNumbers are not coerced to booleans — only "true"/"false" strings match booleans
0falsefailSame 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 fieldResolves toDescription
pathanyThe raw value at the given path — the default and most common source field
typestringThe type of the value at the path: "string", "number", "boolean", "object", "array", or "null"
countnumberLength of an array or string at the path
keysarraySorted array of key strings from an object at the path
valuesarrayArray of values from an object at the path (sorted by key)
entriesarrayArray 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.