Assertions
Assert on your step's response, inter-service traffic, and console logs.
Each step can have an assertions array of blocks. Block type is determined by shape — there are three kinds:
Self block
Asserts on the step's own response (HTTP response or DB query result). A self block has no match field.
{
"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 }
]
}Match block
Asserts on inter-service traffic captured by the interceptor sidecar. A match block uses the match field with a path to select the data source and where clauses to filter entries.
{
"match": {
"path": "$.traffic",
"where": [
{ "path": "$$.origin", "operator": "eq", "value": "api-gateway" },
{ "path": "$$.request.method", "operator": "eq", "value": "POST" },
{ "path": "$$.request.url", "operator": "contains", "value": "user-service/api/users" }
],
"count": 1
},
"assertions": [
{ "path": "$.match.request.body.email", "operator": "eq", "value": "{{email}}" },
{ "path": "$.match.response.status", "operator": "eq", "value": 201 },
{ "path": "$.match.response.body.id", "operator": "exists" }
]
}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 test within the same definition. If you need to assert on traffic triggered by a previous step, keep the assertion in the same test.
Match fields
| Field | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Data source to query: $.traffic for HTTP traffic, $.consoleLogs for console output, $.messageLogs for broker messages, $.dbLogs for database queries |
where | array | No | Filter entries. Each clause uses $$. prefix to reference fields on the matched entry |
count | integer or object | No | Assert how many entries match. An integer asserts an exact count. An object { "operator": "gte", "value": 1 } supports range operators (eq, gt, gte, lt, lte). If omitted, at least one match is expected. |
Where clauses
Each where clause filters entries using $$.-prefixed paths that reference the candidate entry's fields. All clauses must pass for an entry to match (AND logic). All support {{variables}}:
"where": [
{ "path": "$$.origin", "operator": "eq", "value": "api-gateway" },
{ "path": "$$.request.method", "operator": "eq", "value": "POST" },
{ "path": "$$.request.url", "operator": "contains", "value": "/api/users" }
]You can 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" } }
]Assertion paths inside match blocks
Inside a match block, assertion paths reference the matched entry via $.match.* (the first/default match) or $.lastMatch.* (the last match):
"assertions": [
{ "path": "$.match.response.status", "operator": "eq", "value": 200 },
{ "path": "$.match.request.body.name", "operator": "exists" }
]Iterating over multiple matches
To assert on each matching entry individually, use a forEach block:
{
"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 }
]
}
}Console log block
Asserts on console output from a specific service during the step's execution. Console assertions use the same match structure with path: "$.consoleLogs":
{
"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
}
}To 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
}
}To assert on message content 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 where fields
| Path | Type | Description |
|---|---|---|
$$.service | string | Service name that emitted the log |
$$.level | enum | Log level: INFO, WARN, ERROR, DEBUG |
$$.message | string | Log message content |
Message log block
Asserts on broker messages (AMQP or Kafka) captured during the step. Uses match with path: "$.messageLogs":
{
"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 where 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) |
Assertion paths
All assertion and extract paths use the unified root context. Paths must start with $. followed by the document root field.
HTTP responses (self block or match block)
| 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 |
Database query results (self block only)
| 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 |
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 | Unified containment: 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 and edge cases
The eq and ne operators use loose comparison with automatic type coercion. Understanding these rules helps avoid surprising results, especially with typed variables.
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 float64(42) |
"1.0" | 1 | pass | Both parse as float64(1) |
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. This means non-string values are stringified: numbers become "42", booleans become "true"/"false", objects become their Go fmt.Sprintf("%v") representation (which may not match JSON). For reliable results, use string operators on string values.
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:
| Source field | Resolves to | Description |
|---|---|---|
type | string | The JavaScript type of the value at the given path: string, number, boolean, object, array, null |
count | number | Length of an array or string at the given path |
keys | array | Array of key strings from an object at the given path |
values | array | Array of values from an object at the given path |
entries | array | Array of { key, value } objects from an object at the given path |
Examples:
// Type check (replaces the old "type" operator)
{ "type": "$.response.body.items", "operator": "eq", "value": "array" }
// Length check (replaces the old "length" operator)
{ "count": "$.response.body.items", "operator": "eq", "value": 3 }
// Object keys
{ "keys": "$.response.body.config", "operator": "contains", "value": "timeout" }ValueRef
Instead of a literal value, you can compare against another path in the document 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.