Skip to content

Matchers

Matchers are functions that, when placed inside an expect block, describe a shape the response must satisfy rather than a literal value. They are the difference between fragile equality assertions and resilient structural ones.

Tales matchers fall into three groups: value matchers (does this value satisfy the predicate?), field matchers (is this JSON key present, optional, etc.?), and soft helpers (can).

The actual value must contain the expected one. Works on strings (substring) and arrays (membership).

headers = {
Content-Type = contains("application/json")
}
json = {
tags = contains("smoke")
}

Regex match (Go RE2 syntax). Compiled once when the assertion is built.

json = {
request_id = matches("^req-[a-f0-9]{16}$")
}

The actual value must equal one of the listed values. Use it for cleanup steps that should tolerate “already deleted” / “never created” status codes:

expect {
status = one_of([200, 204, 404])
}

is_string(), is_number(), is_bool(), is_array(), is_object()

Section titled “is_string(), is_number(), is_bool(), is_array(), is_object()”

Type predicates. The actual value must be of the expected JSON type.

json = {
id = is_string()
count = is_number()
active = is_bool()
tags = is_array()
metadata = is_object()
}

Accepts any value, including null. Useful inside optional() when you only want to assert presence-or-absence, not type:

json = {
metadata = optional(any()) // either absent or any value
}

These describe the presence of a JSON key, not its value.

Explicit “this key must be present”. The default behaviour for keys you write in expect.json, required() just makes the intent visible.

json = {
id = required(is_string())
}

The key may be absent. If it is present, the inner matcher must satisfy.

This is the right matcher for ConnectRPC / protobuf JSON responses where fields equal to their zero value are typically omitted:

json = {
role = optional("ROLE_UNSPECIFIED")
display_name = optional("")
permissions = optional([])
metadata = optional(any())
}
PatternMeaning
optional("default")Key absent, or equal to the explicit default literal.
optional(any())Key absent, or any value present.
optional(is_string())Key absent, or present with a string value.
optional(contains(...))Key absent, or present and satisfying the inner matcher.

Asserts the key is present (any value). Equivalent to required(any()) but reads better when you only care about presence.

json = {
trace_id = exists()
}

Asserts the key is absent. Useful when an API change should remove a deprecated field.

json = {
legacy_token = not_exists()
}

By default, JSON assertions ignore keys that are present in the response but not listed in expect.json. Setting strict = true flips that: any extra key fails the step.

expect {
json = {
id = is_string()
}
strict = true // fails if the response carries any other key
}

Use strict for API contracts where unexpected fields signal a regression.

can(expr) evaluates expr and returns true if it succeeded without error, false if it threw. Use it in when clauses to gate on the presence of an upstream capture without writing fragile null checks.

teardown {
step "http" "delete_user" {
when = can(result.create_user.id)
request {
method = "DELETE"
url = "${config.base_url}/users/${result.create_user.id}"
}
}
}

If result.create_user.id was not captured (the step failed or was skipped), the teardown step is reported as skipped with reason when condition was false.

Matchers compose. The most common composition is optional(contains(...)):

json = {
status = "completed"
error = optional("") // absent or empty string
message = optional(contains("retry")) // absent or substring match
recipients = optional(is_array()) // absent or any array
trace_id = required(matches("^trc_")) // present and regex match
}

Inside headers, only value matchers make sense (HTTP headers are flat key/value pairs, all present or absent at the protocol level):

headers = {
Content-Type = contains("json")
X-Request-Id = matches("^req-")
X-Rate-Limit = is_string()
}

A few things that look like matchers but aren’t:

  • generate("name"), produces a value, not a predicate.
  • env("NAME"), reads env, produces a value.
  • jsonencode(v), hmac_sha256_hex(k, m), regex_find(s, p), value-producing helpers, not predicates.

If you find yourself writing json = { field = generate("x") }, you’re asserting the field equals a generated value, that works because generators are deterministic, but the test will break if the generator config changes. Capture the value once and reference the capture instead.