skip to content

A Karate step reads `* def id = get[0] $.items[?(@.type == 'special')].id` and the `match` after it passes in CI, even though no item in the response has that type. What is Karate doing, and how do you stop a bad filter from passing silently?

level: seniorimportance: should knowfreq 44%

answer

  1. Nothing matched is a result, not an error
  2. Empty result stays chainable on purpose
  3. A missing branch becomes a marker string
  4. Prove the selection before reaching inside
  5. Assert the count next to the filter

basics

~20 s

A filter that matches nothing yields an empty list, not an error, and get[N] hands it back untouched; a missing property degrades to #notpresent. A bad path produces a value, so the assertion can pass vacuously.

solid answer

~50 s

Two deliberate degradations are in play. First, a filter or wildcard is an **indefinite** path: matching nothing yields an empty list rather than an error, and `get[N]` deliberately hands an empty result set back untouched instead of collapsing it to `null` — that keeps `[].id` chainable as `undefined` rather than throwing. Second, if a property in the path is missing outright, the JsonPath engine raises a path-not-found and Karate catches it, yielding the string `#notpresent` instead of failing the step. So a typo'd field name or a filter value that never matches gives you a *value*, not an error, and whatever you assert against it may pass by accident. The fix is to assert the selection before asserting inside it: compare the whole selected array against the array you expect, or assert its length, so an empty selection fails loudly.

code

gherkin · 10 lines
gherkin
* def response = { items: [{ type: 'normal', id: 'A1' }] }

# the filter matches nothing - this is [] and not an error
* def result = get[0] $.items[?(@.type == 'special')].id
* match result == []

# a branch that is absent altogether degrades to a marker string
* def response = { name: 'test', status: 'active' }
* def missing = get[0] $.items[?(@.type == 'special')].id
* match missing == '#notpresent'

go deeper

for a junior

Remember that a path which selects nothing does not error — you get an empty list or a marker string back. Never assume a passing step means the path was right.

for a middle

Explain both degradations and why each exists: the empty list stays chainable, and the missing branch becomes a value you are meant to be able to assert against.

for a senior

Own the guard. Every filter or wildcard that can select nothing needs a count or whole-array assertion beside it, or the suite goes green through a schema rename nobody told you about.

for a principal

Decide where forgiveness belongs. Defaults tuned for authoring speed become silent-pass defects in CI, so the standard has to say which assertions a suite is allowed to write, not just which the tool permits.

## Two silent degradations, both on purpose Karate is deliberately forgiving about paths that do not resolve, and the two behaviours are different enough to be worth separating. **1. An indefinite path that hits nothing is an empty list.** A filter `[?(...)]`, a wildcard `[*]` or a recursive descent `..` is a query. A query that matches no rows returns no rows — that is not an error condition in JsonPath, it is a result. So `$.items[?(@.type == 'special')].id` over a response whose items are all `normal` evaluates to `[]`. The index form then makes a considered choice: **`get[N]` hands an empty result set back untouched rather than collapsing it to `null`.** The reason is chainability — `[].id` is `undefined` in JavaScript, whereas `null.id` throws. It keeps the common `get[0] $..items[?(...)]` idiom usable, at the cost of letting an empty selection flow downstream. **2. A missing property degrades to `#notpresent`.** If the path cannot even be walked — `$.items[...]` where the response has no `items` key at all — the JsonPath engine raises a path-not-found, and Karate catches it and produces the string `#notpresent` rather than failing the step. That is what lets `match foo.nope == '#notpresent'` be a legitimate, passing assertion. ## Why the assertion then passes Put the two together and a path that selects nothing never announces itself: | What went wrong | What the step yields | What a careless `match` does | |---|---|---| | Filter value never matches | `[]` | `== []` passes; `contains` over nothing may pass | | Field renamed inside the filter | `[]` | same | | Whole branch missing from payload | `#notpresent` | `== '#notpresent'` passes; a `contains` may too | One neighbouring case does **not** belong in that table, and it is worth separating because it cuts the other way. **Indexing past the end of a result that does have rows is not silent on Karate 1.x.** The index is applied there with a bare `list.get(index)` guarded only by "the list is not empty", so `get[3]` over a three-row selection throws `IndexOutOfBoundsException` and the step fails loudly. The forgiveness this question is about is the *empty* selection and the *missing* property — not the out-of-range index. Karate 2.x added the bounds check and returns `null` there instead, which does move that case into the silent column. The worst version is the one in the question: `get[0]` on an empty result gives `[]`, so the variable is *defined*, the next step runs, and an assertion written loosely enough — a `contains`, or a comparison against something that is also empty — reports green. The suite is not testing the endpoint any more; it is testing that Karate can evaluate an expression. ## How to make it fail loudly 1. **Assert the selection, not just inside it.** Compare the whole selected array to the array you expect: `match response.items[*].id == ['A1', 'B2']`. An empty selection fails immediately, because `[]` is not `['A1', 'B2']`. 2. **Assert the count before indexing.** If you genuinely want one row out of a filter, prove there is one first — `assert filtered.length == 1` — and only then take `filtered[0]`. 3. **Do not index a filter result you have not checked.** `get[0]` is a convenience, not a guarantee. Treat every `get[0]` over a filter as a place where a count assertion belongs. 4. **Pin the shape of the container.** Asserting that `items` is present and non-empty, in its own step, separates "the endpoint returned nothing" from "the endpoint returned the wrong thing" — two failures with very different causes and very different on-call responses. 5. **Review filters as data, not as syntax.** A filter compares against a literal; when the literal is an enum value or a status that the service renamed, nothing about the test text changes. A count assertion is the only thing standing between that rename and a green build. A useful way to hold all five: the guard is not about distrusting Karate, it is about the difference between an expression that **evaluated** and an assertion that **checked something**. Those are not the same event, and only the second one is what CI is paying for. ## Where else the same shape hides - A `contains` written against a selection that can be empty — membership over nothing is not a failure. - A `get[0]` fed straight into another path, so the empty list is two steps away from the assertion. - A recursive descent `..` used because the exact nesting was unclear; it selects nothing just as quietly as a filter does. - An assertion against `'#notpresent'` copied from a genuine absence test into a place where the value should have been there all along. - A selection whose filter compares against a status or enum the service owns, where a rename on their side changes nothing on yours. ## The wider habit The general shape of this defect is *a test whose failure mode is to select nothing*. It is not unique to Karate — it is the same class as an empty result set in a database assertion, or a locator that matches zero elements. What is specific here is that Karate's forgiveness is a documented, deliberate design choice: `#notpresent` is a first-class value you are expected to assert against, and an empty list is expected to stay chainable. Both are good defaults for authoring; both need a guard once the test is in CI and nobody is watching it run. The habit to build is simple: **every selection that can be empty gets an assertion that it is not**, written on its own line, before anything reaches into it.

  • Why does the index form hand back an empty list instead of `null`?
    Chainability. `[].id` is `undefined` in JavaScript and flows on, whereas `null.id` throws and would blow up the step with an error that points at the wrong place. Returning the empty result set keeps the common `get[0]` over a filter usable in a longer expression — the trade is exactly the silence this question is about.
  • Is `#notpresent` a failure?
    No — it is a value, and asserting against it is a legitimate, passing test: `match foo.nope == '#notpresent'` is how you prove a key is absent. That is precisely why it cannot double as an error signal; a path that yields it has not failed, it has answered.
  • How would you catch this class of defect across a suite that already exists?
    Grep for indexing into a filter or wildcard result — every `get[0]` over a `[?(` or `[*]` — and check whether a count or whole-array assertion sits near it. Where none does, add one. The same sweep catches assertions written as `contains` against a selection that could be empty, which is the other spelling of the same hole.

A filter is a sieve, not a lookup. Pour nothing through it and you get an empty bucket back, not a complaint — and an empty bucket weighs exactly what you expected if you never checked what was in it.

saying these in an interview costs you the question

  • Assumes an unmatched filter throws or fails the step
  • Thinks #notpresent means the assertion failed
  • Indexes a filter result without proving it is non-empty
  • Says an empty selection collapses to null
  • Treats a green run as proof the path resolved
  • Adds contains to quiet a failure without checking the count