skip to content

In REST Assured, why does then().body(path, equalTo(0.82)) fail on a JSON decimal?

level: juniorimportance: should knowfreq 45%

answer

  1. the box type, not the arithmetic
  2. decimals narrow, integers do not
  3. equalTo calls equals
  4. one letter fixes it
  5. or change the number return type

basics

~20 s

REST Assured parses a JSON decimal into a Float by default. Hamcrest's equalTo compares with equals, so a Java double literal like 0.82 never matches the parsed Float. Write 0.82f instead, or change the configured number return type.

solid answer

~50 s

By default REST Assured materialises a non-integer JSON number as a `java.lang.Float`. The underlying Groovy slurper produces a `BigDecimal`, and REST Assured's own slurper converts it under the default `NumberReturnType.FLOAT_AND_DOUBLE` — to a `Float` unless the value exceeds `Float.MAX_VALUE`, in which case it becomes a `Double`. `equalTo(0.82)` builds a matcher over a boxed `Double`, and `Float.equals(Double)` is false regardless of the arithmetic, so the assertion fails with a message that looks like a rounding problem. The fixes, in order of preference: write the float literal, `equalTo(0.82f)`; or read the value through a coercing getter such as `jsonPath().getDouble("glossary.entry[0].confidence")`; or switch the whole suite to `NumberReturnType.BIG_DECIMAL`. Whole numbers are unaffected — they arrive as `Integer` or `Long`, so `equalTo(3)` matches `reviewCount` normally. The failure message is no help here, because a `Float` and a `Double` holding this value both print as `0.82`; only the box type differs.

code

java · 11 lines
java
Response response = get("/glossaries/ui-strings");

Object confidence = response.jsonPath().get("glossary.entry[0].confidence");
assertThat(confidence, instanceOf(Float.class));

response.then()
    .body("glossary.entry[0].confidence", equalTo(0.82f))
    .body("glossary.entry[0].reviewCount", equalTo(3));

float asFloat = response.jsonPath().getFloat("glossary.entry[0].confidence");
double asDouble = response.jsonPath().getDouble("glossary.entry[0].confidence");

go deeper

for a junior

Recall the one-letter fix: a JSON decimal arrives as a Float, so the literal in the matcher needs the f suffix. Know that whole numbers are not affected.

for a middle

Explain the chain: the slurper produces a BigDecimal, the default FLOAT_AND_DOUBLE model narrows it to a Float, and equalTo compares with equals, which fails on the box type before it ever compares numbers.

for a senior

Show the judgment call: decide the suite's number model once and document it, and argue for band assertions over exact decimals so a re-scored payload does not turn the suite red.

for a principal

Own the consequence of changing the model. BIG_DECIMAL or BIG_INTEGER rewrites the type of every number in every covered response, so treat it as a migration with a blast radius, not a config tweak.

## What actually comes back from a JSON number REST Assured does not hand you the raw text of a JSON number, and it does not hand you a `double` either. The parse runs through `ConfigurableJsonSlurper`, a copy of Groovy's `JsonSlurper` that exists precisely to override the number model. Groovy's own slurper returns a `BigDecimal` for every non-integer literal; REST Assured then post-processes that value according to the configured `NumberReturnType`, whose default is `FLOAT_AND_DOUBLE`. Under that default the rule is: - A non-integer literal becomes a **`Float`**, unless its value is greater than `Float.MAX_VALUE`, in which case it becomes a **`Double`**. - A whole number stays an **`Integer`** or a **`Long`**, chosen by magnitude — no conversion is applied. - Nothing is rounded on the way in; the `BigDecimal` is simply narrowed by calling `floatValue()`. So for the glossary payload returned by `GET /glossaries/ui-strings`: ```json { "glossary": { "entry": [ { "term": "checkout", "confidence": 0.82, "reviewCount": 3 }, { "term": "cart", "confidence": 0.41, "reviewCount": 0 } ] } } ``` `glossary.entry[0].confidence` is a `Float` holding `0.82f`, and `glossary.entry[0].reviewCount` is an `Integer` holding `3`. ## Why the matcher says no `equalTo(0.82)` in Java is `equalTo(Double.valueOf(0.82))`, because `0.82` is a `double` literal. Hamcrest's `equalTo` is an equality matcher: it calls `expected.equals(actual)`. `Double.equals` returns `false` for anything that is not a `Double`, and `Float.equals` returns `false` for anything that is not a `Float`. The comparison never reaches the numeric values at all — it fails on type. The failure message prints both sides in a form that looks numerically identical, which is exactly why this trap costs people an afternoon: ``` JSON path glossary.entry[0].confidence doesn't match. Expected: <0.82> Actual: 0.82 ``` The narrowing is also lossy in a second, quieter way. `0.82` is not exactly representable in binary, and the nearest `float` and the nearest `double` are different numbers. Even if the types matched, comparing a narrowed value against a `double` literal for exact equality would be fragile. ## The four ways out, and when to use each | Fix | What you write | Use it when | |---|---|---| | Float literal | `body("...confidence", equalTo(0.82f))` | the default number model is fine and you want the smallest change | | Coercing getter | `jsonPath().getFloat(path)` / `getDouble(path)` | you are extracting into a variable rather than matching inline | | Range matcher | `body("...confidence", greaterThan(0.5f))` | the exact value is not the point of the test | | Change the model | `NumberReturnType.BIG_DECIMAL` | the payload carries values where precision genuinely matters | A few notes on those: 1. The typed getters on `JsonPath` coerce for you — `getDouble` and `getFloat` funnel whatever the parser produced through a converter, so they work no matter which number model is active. 2. A range matcher still needs the right box type: `greaterThan(0.5)` compares `Double`s and will not accept a `Float` any more than `equalTo` will. 3. Hamcrest's `closeTo(double, double)` is typed for `Double`, so it does not match a `Float` either — reaching for it is a common second mistake after the first one. 4. Switching to `BIG_DECIMAL` changes the type of every decimal in every response the configuration covers, so it is a suite-wide decision, not a per-assertion one. ## Practical guidance - If your assertions are about identity — an id, a count, a status — you will never meet this, because whole numbers are not converted. - If they are about measurements — a confidence score, a ratio, a duration — decide the number model once, write it into the shared configuration, and put a line in the team's README about it. - Prefer asserting a band over an exact decimal wherever the test's intent allows it. "Confidence is above 0.5" survives a re-scoring of the glossary; "confidence is exactly 0.82" does not. - When you do need exactness, `BIG_DECIMAL` with a `BigDecimal` built from a **string** is the honest spelling: `is(new BigDecimal("0.82"))`. Building one from a `double` reintroduces the binary representation you were trying to escape. The underlying lesson generalises past this library: a JSON number has no type in the document, so every parser picks one for you. Knowing which one your parser picked is part of knowing your tools.

  • Does the same trap apply to a whole number such as reviewCount?
    No. Under the default number model whole numbers are left as `Integer`, or `Long` when they are too large, so `equalTo(3)` matches directly. It only bites if you switch the model to `BIG_INTEGER`, which converts those same `Integer` and `Long` results to `BigInteger` and breaks every `equalTo(3)` in the suite at once.
  • Why does the failure message print both values as 0.82?
    The message renders each side with `toString()`, and a `Float` and a `Double` holding the nearest representable value to 0.82 both print as `0.82`. The mismatch is in the box type, which the message does not show. Inspecting the extracted value's class, or asserting with `instanceOf`, is the quickest way to see it.

saying these in an interview costs you the question

  • Blaming floating-point rounding for what is a type mismatch
  • Assuming a JSON decimal arrives as a Java double
  • Reaching for closeTo, which is typed for Double and also fails
  • Switching the whole suite to BIG_DECIMAL to fix one assertion
  • Believing the failure message shows the actual runtime type