JSON の読み方

JSON の 6 つの型と、テストをつまずかせる値。

テストする API のほとんどは JSON を送受信します。構造化データのためのテキスト形式で、午後のひとときで覚えられるくらい 小さなものです。構文が問題になることはめったにありません。問題は、JSON がいくつかのことを決めずに残していて、その隙間を プログラムごとに違うやり方で埋めていることです。その隙間にこそ多くのバグが潜んでいるので、このレッスンでは構文よりも そちらに時間をかけます。

6 種類の値

JSON の値は必ず次のどれかです。

型例メモ
オブジェクト{ "key": "value" }波かっこで囲んだ名前付きのフィールド。キーは常にダブルクォートで囲んだ文字列。
配列[1, 2, 3]角かっこで囲んだ順序付きの一覧。要素はどんな型でもよく、混在してもよい。
文字列"hello"ダブルクォートで囲んだテキスト。シングルクォートは使えない。
数値42, -7, 3.14159, 6.022e23整数と小数で 1 つの型。先頭のゼロ、NaN、Infinity は使えない。
真偽値true, false小文字で、クォートなし。
nullnull小文字で、クォートなし。「値なし」を意味する。

型の仕組みはこれで全部です。日付の型も、小数と区別された整数の型もなく、コメントを書く方法もありません。この API には、それぞれを 1 つずつ返すエンドポイントがあります。

GET/api/edge-cases/types
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
JSON
{
  "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"
}

最後の 3 つのフィールドを見てください。"123"、"true"、"null" はクォートで囲まれているので、どれも文字列です。レスポンスを読む人には数値、真偽値、null に見えますが、プログラムには 3 つの文字列に見えます。レスポンスを確かめるときは、値だけでなく型も確かめてください。ある日は "price": "249.99" を、次の日は "price": 249.99 を返す API は、数字が同じに見えても契約を変えています。

値を指し示す

テストやバグ報告を書くときには、どの値のことを言っているのかを示す方法が必要です。次は最初のレッスンの商品の一部です。

JSON
{
  "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"
}

よく使われる略式の書き方では、オブジェクトの中に入るにはドットを、配列から要素を選ぶには数字入りの角かっこを使います。 数えるのは 0 からです。

パス値
title"Wireless Noise-Cancelling Headphones"
rating.rate4.3
rating.count127

/api/Products?limit=2 のようにレスポンス全体が配列のときは、最初の商品の ID が [0].id、2 番目の商品の 評価が [1].rating.rate です。ツールによって書き方は少し違います。Postman、REST Assured、多くのテストライブラリ で見かける JSONPath は、ルートをドル記号で表します。$.rating.rate、$[0].id のようにです。ワイルドカードもあり、$[*].price は「すべての要素の価格」を意味します。AWS のコマンドラインツールが 使う JMESPath では、同じことを [*].price と書きます。どちらもまだ深く学ぶ必要はありません。$[0].id が「最初の要素の id」を意味するとわかれば、テストのコードはほとんど読めます。

null、欠落、空

これらは似て見えますが、意味が違います。ミドルネームのないユーザーがいるとしましょう。API はそれを少なくとも 5 通りで表せます。

  • "middleName": null:フィールドはあるが値がない
  • middleName のキーがそもそもない
  • "middleName": "":空の文字列
  • "middleName": "null":null という 4 文字の単語。たいていは null に toString を呼んでしまったバグ
  • "middleName": "None" や "undefined":Python や JavaScript から来た同じバグ

このエンドポイントは、それらをすべて並べて返します。

GET/api/edge-cases/nulls
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
JSON
{
  "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
}

どれが正しいかは API のドキュメント次第ですが、ドキュメントに書かれていないこともよくあります。テストできるのは一貫性です。 ある商品は "discount": null を持ち、別の商品には discount のキーがないとすれば、product.discount.amount を読むクライアントは一方では落ち、もう一方では落ちません。0 や false が「不明」の意味で使われていないかにも注意してください。在庫が 0 であることと、在庫が誰にもわからないことは別の事実です。

数値

JSON は数値の大きさにも精度にも制限を設けていません。制限があるのは JSON を読むプログラムの方です。JavaScript はすべての数値を 64 ビットの浮動小数点数として持つので、整数を正確に保てるのは 9,007,199,254,740,991 までです。それを超えると、黙って丸めます。

GET/api/edge-cases/numbers
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
JSON(一部)
{
  "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"
}

このレスポンスを JavaScript と Python で解析して比べてみましょう。

JavaScript
JSON.parse(body).beyondSafeInteger   // 9007199254740992
JSON.parse(body).int64Max            // 9223372036854776000
Python
json.loads(body)["beyondSafeInteger"]   # 9007199254740993
json.loads(body)["int64Max"]            # 9223372036854775807

JavaScript はエラーを出さずに 9007199254740993 を 9007199254740992 に変えました。Python は正確なまま保ちました。API が 64 ビット整数の ID を使っていると(Twitter の ID がそうで、多くのデータベースもそうです)、 JavaScript のクライアントが違うレコードを求めてしまうことがあります。慎重な API が上の int64MaxAsString のように大きな ID を文字列で送るのはそのためです。大きな数値の ID を持つ API をテストするときは、ブラウザのクライアントが 実際に何を受け取るかを確かめてください。

小数にも似た問題があります。2 進の浮動小数点数は 0.1 を正確に表せないので、0.1 + 0.2 はほとんどの言語で 0.30000000000000004 になります。お金を扱うときにはこれが問題になります。価格を数値(19.99)で送る API もあれば、文字列("19.99")で、あるいは整数のセント(1999)で送る API もあります。どれでもうまくいきえます。テストでは、合計が「だいたい合っている」ではなく、1 セント単位で合っていることを確かめるべきです。

同じレスポンスからもう 2 つ、小さなことを。1.50 と 1.5 は解析すれば同じ数値なので、生のテキストを比べるテストは、値を比べるテストが通るところで失敗します。また -0 は有効な JSON ですが、JavaScript の JSON.stringify(-0) は 0 と書くので、往復すると残りません。

日付は文字列

JSON には日付の型がないので、日付は文字列か数値で運ばれ、形式は API ごとに違います。次は同じ瞬間をいくつもの書き方で 表したものです。

GET/api/edge-cases/dates
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
JSON(一部)
{
  "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": ""
}

いちばんよく見るのは 2025-07-15T09:30:00Z のような、タイムゾーン付きの ISO 8601 です。Z は UTC を意味します。API が日付を返すときに確かめることは次のとおりです。

  • タイムゾーンがあること。ゾーンのない 2025-07-15T09:30:00 は「どこかの 9 時 30 分」で、クライアントはそれぞれ違う「どこか」を推測します。
  • Unix タイムスタンプが秒なのかミリ秒なのかが明らかなこと。1752571800 と 1752571800000 は同じ瞬間で、取り違えると 1970 年か西暦 57500 年に飛んでしまいます。
  • 07/15/2025 と 15/07/2025 があいまいでないのは、15 が月になりえないからにすぎません。03/04/2025 は読む人によって 3 月にも 4 月にもなります。
  • 2025-02-29 のようなありえない日付は、受け付けるパーサーも拒むパーサーもあります。日付を入力として受け取る API に送ってみてください。

キーの順序と重複したキー

オブジェクトのキーに順序はありません。{"a": 1, "b": 2} と {"b": 2, "a": 1} は同じオブジェクトです。ほとんどのサーバーはたまたま毎回同じ順序でキーを送るので、レスポンス全体を文字列として比べたくなり ます。そのテストは、ライブラリの更新でキーの順序が変わった日に、何も悪くないのに壊れます。代わりに解析した値を比べてください。 一方、配列の順序には意味があります。[1, 2] と [2, 1] は別の配列です。

重複したキーはもっと奇妙なケースです。JSON の標準は、キーは一意で「あるべき」と言うだけで、そうでなければならないとは言って いないので、次のものも技術的には許されます。

GET/api/edge-cases/duplicate-keys
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
JSON
{
  "id": 1,
  "name": "first value",
  "name": "second value",
  "nested": { "flag": true, "flag": false },
  "id": 2
}

JavaScript の JSON.parse も Python の json.loads も最後の値を残し、{"id": 2, "name": "second value", "nested": {"flag": false}} になります。ほかのパーサーには、最初の値を残すものや、文書そのものを拒むものもあります。ゲートウェイが最初の role キーを確かめ、バックエンドが 2 番目を読むと、その違いはセキュリティホールになります。レスポンスで重複したキーに出会うことは めったにありませんが、入力の扱いをテストするときには、リクエストに入れて送ってみる価値があります。

JSON が壊れているとき

サーバーがそもそも有効な JSON ではないものを送ってくることがあります。途中で切れたボディ、HTML のエラーページ、 スタックトレースなどです。次は JSON だと名乗りながら、そうではないレスポンスです。

レスポンス
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru

"b" の後に余分なカンマがあり、ボディは true の途中で終わっています。ステータスは 200 で、Content-Type ヘッダーは application/json と言っているので、クライアントは解析しようとして失敗します。Node.js は Unexpected token ']'、Python は Illegal trailing comma before end of array と報告します。実際の API では、これはたいてい、サーバーがレスポンスを書いている途中で落ちたか、プロキシが途中で切ったことを意味します。テストへの 教訓は、どの確認でもボディを解析することです。解析できないボディの 200 は失敗であり、ステータスコードしか見ないテストは それを見逃します。

大きなレスポンスを読む

実際のレスポンスは数百行になることもよくあります。細部を確かめる前に、まず形をつかみましょう。いちばん上の階層は オブジェクトか配列か、要素はいくつあるか、1 つの要素にどんなフィールドがあるか。それから 1 つの要素を最後まで読みます。JSON のバグの多くは形だけで見えます。結果が 1 件のときにだけ一覧が単一のオブジェクトで返って くる、あるエンドポイントでは余分な data キーで包まれているのに別のエンドポイントでは包まれていない、といったものです。

演習

GET /api/Countries/code/INサインインして表示 を送り、レスポンスから次の問いに答えてください。パスはこのレッスンのドット記法で書いてください。

  • population と callingCode はそれぞれどんな型ですか。なぜ違うのでしょうか。
  • 通貨記号へのパスは何ですか。
  • 2 番目の言語へのパスは何ですか。
  • flag フィールドは絵文字です。curl で生のレスポンスを見て、ブラウザの表示と比べてください。何が違い、それは クライアントにとって問題になりますか。

次に /api/edge-cases/stringsサインインして表示 を開き、ブラウザでは問題なく見えても、それを保存したり表示したりするプログラムを壊しかねない値を少なくとも 2 つ見つけてください。

練習とその先へ