Reading JSON
The six JSON types and the values that trip up tests.
Most APIs you will test send and receive JSON. It is a text format for structured data, and it is small enough to learn in an afternoon. The syntax is rarely the problem. The problem is that JSON leaves a few things undefined, and different programs fill those gaps differently. Those gaps are where a lot of bugs live, so this lesson spends more time on them than on the syntax.
Six kinds of value
A JSON value is always one of these:
| Type | Example | Notes |
|---|---|---|
| Object | { "key": "value" } | Named fields in curly braces. Keys are always strings in double quotes. |
| Array | [1, 2, 3] | An ordered list in square brackets. Items can be of any type, mixed. |
| String | "hello" | Text in double quotes. Single quotes are not allowed. |
| Number | 42, -7, 3.14159, 6.022e23 | One type for integers and decimals. No leading zeros, no NaN, no Infinity. |
| Boolean | true, false | Lowercase, no quotes. |
| Null | null | Lowercase, no quotes. Means “no value”. |
That is the whole type system. There is no date type, no integer type separate from decimals, and no way to add comments. This API has an endpoint that returns one of everything:
/api/edge-cases/types{
"string": "hello",
"emptyString": "",
"integer": 42,
"negativeInteger": -7,
"float": 3.14159,
"exponent": 6.022e23,
"booleanTrue": true,
"booleanFalse": false,
"nullValue": null,
"array": [1, 2, 3],
"emptyArray": [],
"object": { "key": "value" },
"emptyObject": {},
"arrayOfObjects": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ],
"nestedArrays": [[1, 2], [3, [4, [5]]]],
"stringThatLooksLikeNumber": "123",
"stringThatLooksLikeBoolean": "true",
"stringThatLooksLikeNull": "null"
}Look at the last three fields. "123", "true" and "null" are all strings, because they are in quotes. A person reading the response sees a number, a boolean and a null. A program sees three strings. When you check a response, check the type as well as the value. An API that returns "price": "249.99" one day and "price": 249.99 the next has changed its contract, even though the number looks the same.
Pointing at a value
When you write a test or a bug report, you need a way to say which value you mean. Here is part of the product from the first lesson:
{
"id": 1,
"title": "Wireless Noise-Cancelling Headphones",
"price": 249.99,
"category": "electronics",
"rating": { "rate": 4.3, "count": 127 },
"inStock": true,
"createdAt": "2025-06-12T10:30:00Z"
}The usual informal notation uses a dot to go into an object and square brackets with a number to pick an item from an array. Counting starts at zero.
| Path | Value |
|---|---|
title | "Wireless Noise-Cancelling Headphones" |
rating.rate | 4.3 |
rating.count | 127 |
When the whole response is an array, as it is for /api/Products?limit=2, the first product's ID is [0].id and the second product's rating is [1].rating.rate. Tools write this slightly differently. JSONPath, which you will see in Postman, REST Assured and many test libraries, starts with a dollar sign for the root: $.rating.rate, $[0].id. It also has wildcards, so $[*].pricemeans "the price of every item". JMESPath, used by the AWS command line tools, writes the same thing as [*].price. You don't need to learn either in depth yet. Knowing that $[0].idmeans "the id of the first item" is enough to read most test code.
Null, missing and empty
These look similar and mean different things. Suppose a user has no middle name. An API could say so in at least five ways:
"middleName": null, the field exists and has no value- no
middleNamekey at all "middleName": "", an empty string"middleName": "null", the four-letter word null, usually a bug where something called toString on a null"middleName": "None"or"undefined", the same bug from Python or JavaScript
This endpoint returns all of them next to each other:
/api/edge-cases/nulls{
"nullValue": null,
"nestedNull": { "a": null, "b": { "c": null } },
"arrayOfNulls": [null, null, null],
"arrayWithNull": [1, null, 3],
"nullAsString": "null",
"undefinedAsString": "undefined",
"noneAsString": "None",
"emptyString": "",
"emptyArray": [],
"emptyObject": {},
"zero": 0,
"false": false
}Which of these is right depends on the API's documentation, and the documentation often doesn't say. What you can test is consistency. If one product has "discount": null and another has no discount key, a client that reads product.discount.amount will crash on one of them and not the other. Also watch for 0 and falsebeing used to mean "unknown". A stock count of 0 and a stock count nobody knows are different facts.
Numbers
JSON puts no limit on the size or precision of a number. The programs that read JSON do. JavaScript stores every number as a 64-bit float, which can hold integers exactly only up to 9,007,199,254,740,991. Above that, it rounds silently.
/api/edge-cases/numbers{
"int64Max": 9223372036854775807,
"int64MaxAsString": "9223372036854775807",
"maxSafeInteger": 9007199254740991,
"beyondSafeInteger": 9007199254740993,
"beyondSafeIntegerAsString": "9007199254740993",
"pointOnePlusPointTwo": 0.30000000000000004,
"money": 19.99,
"moneyAsString": "19.99",
"trailingZeros": 1.50,
"negativeZero": -0,
"numberAsString": "42",
"nanAsString": "NaN"
}Parse that response in JavaScript and in Python and compare:
JSON.parse(body).beyondSafeInteger // 9007199254740992
JSON.parse(body).int64Max // 9223372036854776000json.loads(body)["beyondSafeInteger"] # 9007199254740993
json.loads(body)["int64Max"] # 9223372036854775807JavaScript turned 9007199254740993 into 9007199254740992without any error. Python kept it exact. If an API uses 64-bit integer IDs (Twitter's did, and so do many databases), a JavaScript client can end up asking for the wrong record. That is why careful APIs send large IDs as strings, like int64MaxAsString above. When you test an API with big numeric IDs, check what a browser client actually receives.
Decimals have a related problem. 0.1 + 0.2 is 0.30000000000000004in almost every language, because binary floats can't represent 0.1 exactly. For money this matters. Some APIs send prices as numbers (19.99), some as strings ("19.99"), and some as integer cents (1999). Any of them can work. A test should check that totals add up to the cent, not that they are "close enough".
Two smaller things from the same response. 1.50 and 1.5 are the same number once parsed, so a test that compares raw text will fail where a test that compares values passes. And -0 is legal JSON, but JavaScript's JSON.stringify(-0) writes 0, so it won't survive a round trip.
Dates are strings
JSON has no date type, so dates travel as strings or numbers, and every API picks its own format. Here is one moment in time written several ways:
/api/edge-cases/dates{
"iso8601Utc": "2025-07-15T09:30:00Z",
"iso8601PositiveOffset": "2025-07-15T15:00:00+05:30",
"iso8601NoZone": "2025-07-15T09:30:00",
"dateOnly": "2025-07-15",
"unixSeconds": 1752571800,
"unixMilliseconds": 1752571800000,
"usFormat": "07/15/2025",
"euFormat": "15/07/2025",
"invalidLeapDay": "2025-02-29T00:00:00Z",
"nullDate": null,
"emptyDate": ""
}The format you will see most is ISO 8601 with a zone, like 2025-07-15T09:30:00Z. The Z means UTC. Things to check when an API returns dates:
- The zone is present.
2025-07-15T09:30:00without one means 9:30 somewhere, and every client will guess a different somewhere. - Unix timestamps say whether they are seconds or milliseconds.
1752571800and1752571800000are the same instant, and mixing them up puts you in 1970 or the year 57500. 07/15/2025and15/07/2025are unambiguous only because 15 can't be a month.03/04/2025is March or April depending on who reads it.- Impossible dates like
2025-02-29are accepted by some parsers and rejected by others. Try sending them to an API that takes dates as input.
Key order and duplicate keys
The keys of an object have no order. {"a": 1, "b": 2} and {"b": 2, "a": 1}are the same object. Most servers happen to send keys in the same order every time, so it's tempting to compare the whole response as a string. That test breaks the day a library upgrade reorders the keys, and nothing is actually wrong. Compare parsed values instead. Array order, on the other hand, does matter: [1, 2] and [2, 1] are different arrays.
Duplicate keys are a stranger case. The JSON standard says keys "should" be unique, not that they must, so this is technically allowed:
/api/edge-cases/duplicate-keys{
"id": 1,
"name": "first value",
"name": "second value",
"nested": { "flag": true, "flag": false },
"id": 2
}JavaScript's JSON.parse and Python's json.loads both keep the last value, giving {"id": 2, "name": "second value", "nested": {"flag": false}}. Other parsers keep the first value or reject the document. If a gateway checks the first rolekey and the backend reads the second, that difference becomes a security hole. You won't meet duplicate keys often in responses, but they are worth sending in requests when you test input handling.
When the JSON is broken
Sometimes a server sends something that isn't valid JSON at all: a truncated body, an HTML error page, a stack trace. Here is a response that says it is JSON and isn't:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": truThere is a trailing comma after "b", and the body stops in the middle of true. The status is 200 and the Content-Type header says application/json, so a client will try to parse it and fail. Node.js reports Unexpected token ']'. Python reports Illegal trailing comma before end of array. In a real API this usually means the server crashed partway through writing the response, or a proxy cut it off. The lesson for tests: parse the body as part of every check. A 200 with an unparseable body is a failure, and a test that only looks at the status code will miss it.
Reading a large response
Real responses are often hundreds of lines. Before checking details, get the shape: is the top level an object or an array, how many items are there, which fields does one item have. Then read one item fully. Most JSON bugs are visible from the shape alone, such as a list that comes back as a single object when there is one result, or an object wrapped in an extra data key on one endpoint and not on another.
Exercise
Send GET /api/Countries/code/INsign in to see and answer these from the response, writing each path in the dot notation from this lesson:
- What type is
population, and what type iscallingCode? Why might they differ? - What is the path to the currency symbol?
- What is the path to the second language?
- The
flagfield is an emoji. Look at the raw response with curl and compare it with what your browser shows. What is different, and does it matter to a client?
Then open /api/edge-cases/stringssign in to see and find at least two values that would look fine in a browser but could break a program that stores or displays them.