Reference

Assertions

An assertion is an expression string that must be a boolean at compile time. body.ok alone is refused; write body.ok == true. Every assertion compiles when the plan loads, against the names its step kind provides, so a misspelled name, an unknown field of a typed value or a comparison that can never be boolean is a load error with the plan line and the column in the expression, and nothing is sent. The syntax is expr plus the forms below, which vero rewrites into expr before compiling.

      - http: { method: GET, url: "{{ env.baseUrl }}/orders" }
        repeat: { count: 4, mode: serial }
        assert:
          - responses count 4
          - responses[0:3] all (it.status == 200)
          - responses[3].status == 429
          - responses[3].headers["retry-after"] != nil
          - duration.ttfb < 200ms

Every step except waitFor must assert something. A step with an empty assert is refused: step 1 of task orders has no assertions; a step that asserts nothing passes whatever happens. Some step kinds add an assertion of their own when the plan does not cover the case: a graphql step asserts body.errors == nil or len(body.errors) == 0 unless one of its assertions reads body.errors, a grpc step asserts code == "OK" unless one reads code, and a ws or grpc step with expect asserts messages count N. Each step page says which.

Written forms

The rewrite is a tokenizer and a small parser, not a text substitution. A string literal is never rewritten, so body.note == "5 count 4" compares a string.

Written Means
responses count 4 len(responses) == 4
responses where (it.status == 429) count 1 how many elements match
responses where (it.status == 429) the matching elements, as a list
responses all (it.status == 200), any, none every, at least one, no element holds; it is the element
body.items all (.qty > 0) .qty is short for it.qty inside a predicate
tasks.create-order.results.orderId a hyphenated task name is a name, not a subtraction
duration.ttfb < 200ms, 1m30s, 1.5s duration literals; units ns, us, µs, ms, s, m, h
body.total == 10.10, 1e3, 10000000000000001 a number compares by its source text, exactly
0x1F, 0o17, 0b101 expr's integer literals pass through
exists body.x, missing body.x present (a JSON null counts as present) versus absent
body.p99 ~= 12.5 ± 0.1, +/- 0.1 approximate comparison, on numbers or durations
body matches schema "schemas/order.json" JSON Schema, draft 2020-12, relative to the plan
response matches openapi "openapi.yaml" the whole exchange against an OpenAPI 3.1 document
body matches snapshot "snapshots/order.json" ignoring ["id", "items[*].createdAt"] the whole value against a JSON file; ignoring is optional
"x" not in body.tags, not contains, not matches, not startsWith, not endsWith the negated word operators

Everything expr offers works as well: and, or, not, in, contains, startsWith, endsWith, matches with a regular expression, len(), filter(), map(), count(), slices responses[0:3], the conditional a ? b : c, and ?. for an optional field.

A form written wrongly is a load error at its column. Among them: count needs a collection on its left, as in responses count 4; count needs a number on its right, as in responses count 4; all needs a collection on its left and a parenthesised condition on its right, as in responses all (it.status == 200); where needs a collection on its left and a parenthesised condition on its right, as in responses where (it.status == 429); exists needs a path such as body.order.id; ~= is written A ~= B ± T (or +/- T), as in body.metrics.p99 ~= 12.5 ± 0.1; matches schema needs a value on its left and a quoted file name on its right; matches openapi is written response matches openapi "file"; ignoring takes a list of quoted paths, as in ignoring ["id", "items[*].createdAt"]; "200x" is neither a number nor a duration such as 200ms or 1m30s; unterminated string; unbalanced ")"; unclosed (.

Names in scope

Step Names
http, graphql request (method, url), status, proto ("HTTP/1.1" or "HTTP/2.0"), headers, body, duration, redirects (each method, url, status), env, tasks, run
http with repeat the above for the last response, plus responses, each with the same fields
ws status (101 on success), headers of the upgrade response, messages (each body, text, at), closed, duration, env, tasks, run
grpc code, message, body, messages (each body, at), headers, trailers, duration, env, tasks, run
sql rows (a list of column maps), duration, env, tasks, run
fixture rowsAffected, duration, env, tasks, run
exec exitCode (nil if killed), stdout, stderr, truncated.stdout, truncated.stderr, duration, env, tasks, run
script ok, result, duration, env, tasks, run
when guard env, tasks, run
waitFor.until the http names

env is the plan's env block after rendering, tasks.<name>.results.<key> is what another task published, and run.id is the run's ULID. The names are typed. A misspelled top-level name fails at load with a suggestion, unknown name stauts (did you mean status?), or with the kind's names when nothing is close: unknown name foo (a http step's names are body, duration, env, headers, proto, redirects, request, run, status, tasks).

duration is never assertable whole. Name a phase: for http and graphql dns, connect, tls, ttfb, total; for ws connect, tls, upgrade, first, total; for grpc first, total; for sql, fixture, exec and script total only. duration < 1s is refused at load: duration is not assertable on its own; name a phase: duration.ttfb, duration.total or duration.tls (also duration.dns, duration.connect).

A parallel repeat has no order, so responses[3] and responses[0:3] are refused at load: responses[3] indexes a parallel repeat; index 3 has no meaning in a set (chapter 3.5). Assert on the set instead, as in `` responses where (it.status == 429) count 1` ``. A serial repeat keeps send order and allows both.

How values compare

  • Numbers compare exactly. A JSON number is kept as its text and compared as an exact rational, so 10000000000000001 != 10000000000000000 and 10.10 == 10.1. Arithmetic is exact too: +, -, * between integers stay integers, / yields an exact decimal, never a float, and % needs two integers.
  • Strings compare by value; + concatenates; < is lexical.
  • Headers ignore case. headers["Retry-After"], headers["retry-after"] and a name computed at run time all read the same header. A header that is not there is nil. "x-request-id" in headers tests presence.
  • Durations compare with durations. duration.ttfb < 200 is a run-time error: invalid operation: duration < number: compare a duration with a duration literal such as 200ms. Durations add and subtract, and multiply or divide by an integer.
  • A body that is not JSON is a string. JSON is decoded only when the response says application/json or a +json type. An empty JSON body leaves body as nil. A response with the same key twice ends the step errored, not with a guess about which value counts.
  • A phase that did not happen is n/a. On a reused connection dns, connect and tls did not happen. Any comparison, arithmetic or ~= that touches an n/a phase has the outcome n/a, which fails the task rather than passing it.
  • exists and missing walk the path segment by segment. A present JSON null is present, a missing key or an index past the end is absent, and a negative index counts from the end.

Assertions are not evaluated when no response arrived. A step whose request got no answer is errored with the transport's reason, and its assertions are not shown as failed against status = 0.

What a failure shows

A failed assertion is printed with its expression as written, the value of every path it read, and the plan line it sits on. This is one run of vero run against the fixture's /fixtures/big-id route, which answers {"id":10000000000000001,"total":10.10}, with a snapshot file that holds the wrong total:

FAIL  big-id › step 1 › assert #3                                 exact.yaml:13

  GET http://127.0.0.1:18080/fixtures/big-id                      HTTP/1.1 200 OK  0.8ms
    > accept-encoding: gzip
    > user-agent: vero
    < content-length: 38
    < content-type: application/json
    < date: Sat, 03 Oct 2026 02:18:44 GMT
    < {"id":10000000000000001,"total":10.10}

  assert  body.id == 10000000000000000    failed
          body.id = 10000000000000001
          exact.yaml:13
  assert  body matches snapshot "snapshots/big-id.json"    failed
          body = {"id":10000000000000001,"total":10.10}
          snapshot snapshots/big-id.json: body.total: expected 10.5, actual 10.10
          exact.yaml:15

  timing  dns n/a  connect 0.2ms  tls n/a  ttfb 0.3ms  total 0.8ms  (new connection)

0 passed  1 failed  0 errored  0 timed out  0 skipped  0 blocked  0 not run   (1 tasks, 1 requests, 0.0s)

The heading names the first assertion that did not hold. Every assertion that did not hold is listed, and the ones that held are not. After the expression comes its outcome, failed, n/a or error, then the detail lines:

  • Every path the assertion read, as body.id = 10000000000000001, or body.items[0].qty: <error> when reading it raised one. Strings are quoted, a response shows as HTTP 200, a list of responses as [200 200 429] (3 responses, by status), rows as JSON cut at 200 characters with the count, headers as headers(content-type, date).
  • For all and none, which elements broke it: responses[0:3] element 2 fails: it.status = 429, five at most and then responses[0:3]: 4 more elements. For any: body.items is empty, so any (...) is false, or body.items: none of 3 elements matched.
  • n/a: it compares a timing phase that did not happen on this request, so it is not held, with the phase shown as duration.dns = n/a.
  • For a body that was not JSON when the assertion read a field of it: the response body was not JSON (Content-Type "text/plain"), so body has no fields.
  • Schema, OpenAPI and snapshot lines, described below.

The same lines go into the event log's assert event as values and detail, and into the HTML report. Under --repeat, every evaluation records its values, held or not, so the flake report can say which values were observed.

Schema, OpenAPI and snapshot files

All three name a file relative to the plan, as a literal. The file is read and compiled when the plan loads, once, however many assertions name it.

matches schema checks a value against a JSON Schema. Draft 2020-12 is the default when the file does not say, $ref resolves relative to the file, and a remote $ref does not load. Load errors: schema schemas/order.json: open ...: no such file or directory, schema schemas/order.json is not valid JSON: ..., schema schemas/order.json does not compile: .... A failure lists every validation error, leaves only, as the JSON pointer into the value, the keyword and the reason. Against {"id":10000000000000001,"total":10.10} and a schema that requires currency and types total as a string:

  assert  body matches schema "schemas/big-id.json"    failed
          body = {"id":10000000000000001,"total":10.10}
          schema schemas/big-id.json: /  required: missing property 'currency'
          schema schemas/big-id.json: /total  type: got number, want string
          schema.yaml:10

Twenty errors at most are listed, then schema: first 20 errors shown. Numeric bounds print exactly, so maximum against 10.10 says 10.10, not a float's rounding of it.

response matches openapi checks the exchange against an OpenAPI 3.1 document, YAML or JSON. The left operand must be the word response. The operation is found by method and path, with a literal segment beating a template segment, and a path prefix from servers[].url stripped when that helps. The response is found by status, exact first, then 2XX, then default. The body is checked against the schema of its Content-Type, falling back to type/* then */*, and every header the response marks required must be present. A response with no content checks headers only. Load errors name the line: openapi openapi.yaml:1: version "3.0.3"; only OpenAPI 3.1 is supported, whose schemas are JSON Schema 2020-12; openapi openapi.yaml:40: $ref https://example.com/x is remote; only refs inside the document or to files beside it load; a duplicate key is refused by the YAML reader. Failure lines: openapi openapi.yaml: no operation for GET /orders/7/items, openapi openapi.yaml: GET /orders/{id} does not document status 404, openapi openapi.yaml: GET /orders/{id} 200 requires header x-request-id, and the response has none, openapi openapi.yaml: GET /orders/{id} 200 does not document Content-Type "text/html", and schema errors as openapi openapi.yaml: GET /orders/{id} 200: /total type: got string, want number.

matches snapshot compares the whole value against a JSON file: numbers exact, key order ignored, array order kept. An ignoring path skips that path and everything under it, and [*] matches any index, so items[*].createdAt ignores the field on every element. A difference prints each path with both values, relative to the compared value: snapshot snapshots/order.json: body.total: expected 10.5, actual 10.10, ...: body.note: expected absent, actual "x", ...: body.items: expected 2 elements, actual 3, twenty at most and then snapshot snapshots/order.json: first 20 differences shown. A file that does not exist is not a load error, since writing it is how it comes to exist, but comparing against it fails: no snapshot at snapshots/health.json; run with --update-snapshots to write it. A file that exists and is not JSON is a load error. vero run --update-snapshots writes every snapshot file as indented JSON, creating directories as needed, fails each assertion it wrote with snapshot snapshots/health.json written, not compared, and exits 1, so the run that writes is never the green one. It refuses to write a value that holds a secret: snapshot snapshots/login.json not written: the value holds the secret ORDERS_API_KEY.

--strict-paths

body.eror == nil holds on a typo, because the misspelled field is absent and absent reads as nil. vero run --strict-paths closes that hole: a step fails when a path an assertion reads is absent from the result and not listed in the step's optional:.

  assert  body.totl == nil    failed
          --strict-paths: body.totl is absent from the result and not declared in optional:

The paths checked are those rooted at body, headers, responses, rows and result, the names whose contents come from the server and can be absent. Typed names such as status and exitCode cannot be misspelled past the compiler. A path is written the way optional: names it: body.order.id, responses[3].status, headers["retry-after"] in lower case, and body.items[*].qty for a path read inside a predicate, reported as body.items[*].qty (element 2) naming the first element that lacks it. exists body.x is exempt, since it already fails on an absent path. The operand of missing body.x is checked like any other path, so an absence the plan intends is declared in optional:, and an absence it did not expect is a failure. Entries in optional: lose their spaces, and header names in them are lower-cased, before comparison. The flag is off by default and meant for CI, where a plan that silently stopped reading the field it was written for is the expensive case.

vero plan --explain <task> lists the paths --strict-paths will check for each step.

Lints and advice

Two lints warn at load, and --strict turns both into errors with exit 64 before any socket opens. This is vero plan on a plan that trips both:

warning: lint.yaml:10: tasks.items.steps[0].assert[1]: "body.items all (it.qty > 0)" holds on an empty body.items; add `body.items count N` or a len() assertion on it in the same step (chapter 5.5)
warning: lint.yaml:5: lifecycle.strategy: strategy unique, but nothing in the plan references run.id, so nothing it creates is namespaced by the run
ok: 1 tasks, 0 finally tasks; the plan sends 1 HTTP request

With --strict each line starts error (--strict): instead and the load ends with lint.yaml: 2 lint warning(s) are errors under --strict; nothing ran.

The first lint is about all and none, which hold on an empty collection. A top-level all or none over a collection is silenced by any assertion in the same step that sizes that collection: body.items count 3, len(body.items) == 3 either way round, any len(body.items), count(body.items, ...) or filter(body.items, ...). A slice with literal bounds, responses[0:3] all (...), is covered by a size pinned to at least the slice's end, responses count 4. A quantifier inside another's predicate ranges over each element and is not linted. The second lint is about a unique lifecycle that never uses run.id; a template, an env value or an assertion that mentions it satisfies it.

Separately, an assertion past 40 nodes of expression tree, past 2 closures (the predicates of all, any, none, filter, map, count and the like), or with closures nested 2 deep draws advice. body.orders all (it.items all (it.qty > 0)) nests one closure in another and draws:

advice: schema.yaml:14: tasks.nested.steps[0].assert[1]: this assertion is 16 nodes with 2 closure(s) nested 2 deep (advice past 40 nodes, 2 closures or nesting 1); one that size is a program, and a script step may be the honest place for it (chapter 10.3)

Advice is printed and never refused, --strict included. The largest assertion in this repository's plans and examples is 17 nodes with one closure.

Reading another task's results

An assertion may read tasks.<name>.results.<key> of another task. That does not create an edge; it may only rely on one that exists. The loader checks, with these errors:

  • "tasks.health.results.x == 1" reads result x, which task health does not declare
  • "..." reads task payment, which does not exist (did you mean payments?)
  • "..." reads result token of its own task, which no earlier step produces
  • "...": main task create-order references finally task cleanup; finally tasks run after every main task has finished
  • "..." reads a result of task login, but orders does not run after login: the graph has no path from login to orders. Add needs: [login] to orders, or template the value so the edge is derived (chapter 5.5)

A task's results share one namespace across its steps: task login declares result token in step 1 and again in step 2; a task's results share one namespace. Results are evaluated after the step's assertions and only when they held. A result expression that raises an error fails the step with result orderId: <error>, and a result that evaluates to nil is stored as absent, so a downstream template that needs it ends errored by name. A result written { secret: body.token } is scrubbed from every output; the plan format page has the rules.

Compile errors, together

One vero plan over a plan with seven mistakes. Every error carries the plan line, the step and assertion index, and the column in the expression as the author wrote it:

assert-errors.yaml:11: tasks.health.steps[0].assert[0]: column 1: an assertion must be a comparison or another boolean expression; this one is a value whose type is only known at run time (write body.ok == true rather than body.ok) in "body.ok"
assert-errors.yaml:12: tasks.health.steps[0].assert[1]: column 1: unknown name stauts (did you mean status?) in "stauts == 200"
assert-errors.yaml:13: tasks.health.steps[0].assert[2]: column 1: duration is not assertable on its own; name a phase: duration.ttfb, duration.total or duration.tls (also duration.dns, duration.connect) in "duration < 1s"
assert-errors.yaml:14: tasks.health.steps[0].assert[3]: column 15: "200x" is neither a number nor a duration such as 200ms or 1m30s in "body.count == 200x"
assert-errors.yaml:15: tasks.health.steps[0].assert[4]: column 11: count needs a number on its right, as in responses count 4 in "responses count"
assert-errors.yaml:21: tasks.burst.steps[0].assert[0]: column 1: responses[3] indexes a parallel repeat; index 3 has no meaning in a set (chapter 3.5). Assert on the set instead, as in `responses where (it.status == 429) count 1` in "responses[3].status == 200"
assert-errors.yaml:25: tasks.later.steps[0].assert[0]: "tasks.health.results.x == 1" reads result x, which task health does not declare
assert-errors.yaml: 7 load error(s); nothing ran

This page is docs/content/reference/assertions.md in the repository.

verodocs