What are the subtle traps with JSONPath number typing, and the difference between `exists`/`doesNotExist`/`isEmpty`/`isNotEmpty`?
answer
- JSON int -> Integer, decimal -> Double
- .value(1L) fails vs JSON 1
- value(matcher, targetType) coerces
- exists = key resolves; isEmpty = value null/empty
- null field: exists ✓ AND isEmpty ✓
basics
~20 sJsonPath maps small JSON integers to Integer and decimals to Double, so .value(1L) fails against 1. exists/doesNotExist test key presence; isEmpty/isNotEmpty test whether a present value is null/empty vs populated. A present-but-null field exists AND isEmpty.
solid answer
~40 sThe main trap is **type coercion**: Jayway JsonPath deserializes JSON numbers into Java types — small integers to `Integer`, larger to `Long`, decimals to `Double`, `BigDecimal` only if configured. So `jsonPath("$.id").value(1L)` fails against JSON `1` because `Integer` ≠ `Long`; use `.value(1)`, `.value(is(1))`, or the `value(Matcher, Class targetType)` coercion overload. `1` vs `1.0` also differ by type via jsonPath, though `content().json` compares numbers by value. On existence: `exists()`/`doesNotExist()` test whether the **key resolves** at all; `isEmpty()`/`isNotEmpty()` test whether the **resolved value** is null/empty (empty string, array, map) vs populated. Consequently a present-but-null field satisfies both `exists()` and `isEmpty()`, and fails both `doesNotExist()` and `isNotEmpty()`. Getting these confused causes false-positive tests. For array sizing prefer `hasSize(n)` or `$.arr.length()`.
code
java · 16 linesimport static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// TYPE TRAP
// body: {"id":1,"score":9.5}
.andExpect(jsonPath("$.id").value(1)) // OK: Integer == Integer
// .andExpect(jsonPath("$.id").value(1L)) // FAILS: Long != Integer
.andExpect(jsonPath("$.id").value(is(1), Integer.class)) // explicit coercion
.andExpect(jsonPath("$.score").value(9.5)) // Double
// EXISTENCE vs EMPTINESS — body: {"email":null,"tags":[]}
.andExpect(jsonPath("$.email").exists()) // present (as null)
.andExpect(jsonPath("$.email").isEmpty()) // null counts as empty
.andExpect(jsonPath("$.tags").isEmpty()) // empty array
.andExpect(jsonPath("$.passwordHash").doesNotExist()) // key truly absent
.andExpect(jsonPath("$.tags").value(hasSize(0))); // exact size via Hamcrestgo deeper
May not know the typing trap; learns exists vs isEmpty at a basic level.
Knows exists/doesNotExist/isEmpty semantics and hits the number-typing bug occasionally.
Proactively coerces types and picks doesNotExist vs isEmpty correctly for contract tests.
Connects assertion choice to Jackson serialization config and security (leak) guarantees; sets conventions to avoid false-green tests.
**Number typing (the classic gotcha):** Jayway JsonPath, when it evaluates a path to a numeric leaf, deserializes it into a concrete Java type based on the JSON parser's mapping — typically `Integer` for values fitting in an int, `Long` for larger integers, and `Double` for anything with a decimal point. MockMvc's `jsonPath(...).value(Object)` then does an equality check. Because equality is type-sensitive: - JSON `1` → `Integer(1)`. `.value(1)` passes; `.value(1L)` fails (`Long` ≠ `Integer`); `.value(1.0)` fails (`Double` ≠ `Integer`). - JSON `1.0` → `Double(1.0)`. `.value(1)` fails. - Big integers beyond int range → `Long`, so now `.value(...L)` is required. Mitigations: (1) match the runtime type exactly; (2) use Hamcrest with the target-type overload `jsonPath("$.id").value(is(1), Integer.class)` or `value(closeTo(1.0, 0.001), Double.class)`; (3) for whole-object numeric comparisons use `content().json(...)`, whose JSONassert compares numbers by numeric value (so `1` and `1.0` are equal there). This value-vs-type discrepancy between the two APIs is itself worth knowing. **Existence vs emptiness:** - `exists()` — passes if the path **resolves to any value**, including `null` and empty collections. Fails only if the key/path is absent. - `doesNotExist()` — the inverse: passes only if the path does not resolve (key absent). - `isEmpty()` — passes if the resolved value is "empty": `null`, empty string `""`, empty array `[]`, or empty object/map. Requires the path to resolve. - `isNotEmpty()` — passes if the resolved value is present and non-empty. Truth table for `"x"`: - Absent (`{}`): exists ✗, doesNotExist ✓, isEmpty ✗ (fails to resolve), isNotEmpty ✗. - `null` (`{"x":null}`): exists ✓, doesNotExist ✗, isEmpty ✓, isNotEmpty ✗. - `""` / `[]` (`{"x":""}`): exists ✓, doesNotExist ✗, isEmpty ✓, isNotEmpty ✗. - `"a"` / `[1]`: exists ✓, doesNotExist ✗, isEmpty ✗, isNotEmpty ✓. **Practical pitfalls:** - Using `doesNotExist()` to assert a field was **omitted** is correct only if your serializer actually omits it. With `@JsonInclude(Include.NON_NULL)` a null field is dropped → `doesNotExist()`. Without it the field serializes as `null` → you need `isEmpty()` or `.value(nullValue())`. This interacts directly with Jackson config. - Asserting array size: `jsonPath("$.arr").isNotEmpty()` only proves ≥1 element. For an exact count use `jsonPath("$.arr", hasSize(3))`... actually `jsonPath("$.arr").value(hasSize(3))`, or JsonPath's function `jsonPath("$.arr.length()").value(3)`. - Filter/wildcard paths (`$.items[*].id`) return a **list** even for a single match, so match with a collection Hamcrest matcher, not a scalar. - Definite vs indefinite paths: a wildcard/filter path that matches nothing returns an empty list rather than throwing, which can silently pass a `isArray()`/`isEmpty()` when you expected a hard failure. **When this matters:** In contract tests and security tests where "field must be absent" (e.g. never leak `passwordHash`) is a real requirement — using `isEmpty()` when you meant `doesNotExist()` would pass on a leaked null and miss a leaked empty string. Precision here prevents false-green tests.
- A test asserts `jsonPath("$.secret").doesNotExist()` and passes, but production still leaks the field as null. What likely changed?Jackson serialization config. If the DTO drops nulls (`@JsonInclude(NON_NULL)`) the null field is omitted and doesNotExist() passes; if that config is removed, the field serializes as `null` — still "leaked" — but now `exists()` and `isEmpty()` are true while `doesNotExist()` would fail. The test was coupled to serializer behavior.
- How do you robustly assert a numeric field equals 1 regardless of whether it's serialized as int or long?Use the value-based `content().json("{\"n\":1}")` (JSONassert compares numbers by value), or coerce with `jsonPath("$.n").value(is(1), Integer.class)`, or a numeric Hamcrest matcher like `comparesEqualTo`.
saying these in an interview costs you the question
- Assuming `.value(1L)` matches JSON `1`
- Treating `isEmpty()` and `doesNotExist()` as interchangeable
- Thinking a wildcard path returns a scalar rather than a list
- Believing jsonPath compares `1` and `1.0` as equal (content().json does; jsonPath value() does not)