はじめに
Torinoa Tools の中で、個人的に一番使用頻度が高いのがJSON Expected Value Validatorというツールです。API開発をしていると「複数のテストケース(JSON)に対して、特定のキーが期待した型・値になっているか」を一括でチェックしたい場面がよくあります。このツールは、JSONPathで指定した検証ルールをCSVで定義し、複数のJSONテストケースに対して一括適用する仕組みになっています。
何をするツールか
- スキーマ定義CSV(縦持ち): 検証したいJSONPath、期待する型、必須かどうか、比較モードを行ごとに定義
- テストケースJSON(複数): 検証対象のJSONを複数アップロード、またはテキストで入力
- 両者を突き合わせて、各テストケースの各ルールについてpass/failを一覧表示
例えば以下のようなスキーマCSVを書きます。
key,expected_type,required,compare_mode
$.user.id,number,true,
$.user.roles,array,true,unordered
$.user.email,string,false,
JSONPathの評価: jsonpath-plus
パスの評価には jsonpath-plus を使っています。
import { JSONPath } from "jsonpath-plus";
matches = JSONPath({ path: normalizePath(rule.key), json: data, wrap: true });
wrap: true を指定して、マッチが0件・1件・複数件のどのケースでも配列として統一的に扱えるようにしています。これにより、$.items[*].id のような複数マッチするパスも、$.user.id のような単一マッチのパスも、同じロジックで比較処理できます。
ユーザーが $. を省略して user.id のように入力しても動くよう、簡単な正規化もかけています。
function normalizePath(rawKey: string): string {
const key = rawKey.trim();
if (key.startsWith("$")) return key;
if (key.startsWith(".")) return "$" + key;
return "$." + key;
}
比較モードの実装
単純な値の一致だけでなく、4つの比較モードを用意しています。
function compareValues(
expected: unknown,
actualList: unknown[],
mode: CompareMode,
): { pass: boolean; message: string } {
if (mode === "length_only") {
const expectedLen = typeof expected === "number" ? expected : Number(expected);
const pass = actualList.every((a) => structuralLength(a) === expectedLen);
return { pass, message: pass ? t("msgValuePass") : t("msgValueFail") };
}
if (mode === "contains") {
const pass = actualList.every((a) => containsMatch(expected, a));
return { pass, message: pass ? t("msgValuePass") : t("msgValueFail") };
}
const arrayMode: "exact" | "unordered" = mode === "exact" ? "exact" : "unordered";
const pass = actualList.every((a) => deepEqual(expected, a, arrayMode));
return { pass, message: pass ? t("msgValuePass") : t("msgValueFail") };
}
-
exact: 配列の順序も含めて完全一致 -
unordered: 配列の要素集合が一致すればOK(順序は問わない) -
contains: 期待値が実際の値に含まれているか(部分一致) -
length_only: 値そのものではなく、配列の長さ・文字列の長さだけを検証
APIのレスポンスをテストする場合、「配列の中身の順序までは保証されていないが、要素は揃っていてほしい」というケースが多いので、unordered をデフォルトの挙動にしています。JSONPathで複数マッチした場合は、マッチした全要素に対して同じ比較を行い、every で全件passして初めて合格とする設計です。
ハマったバグ:CSVエスケープの盲点
このツールを作る中で一番苦労したのが、CSVのエスケープでした。スキーマ定義のJSONPathには、フィルタ式でダブルクォートを含むものがあります(例: $.items[?(@.type=="premium")])。これをCSVの1フィールドとして扱う場合、RFC 4180に従って正しくクォート処理をしないと、パース時に文字列が壊れます。
export function escapeCsvField(value: string, delim: string): string {
if (
value.includes(delim) ||
value.includes('"') ||
value.includes("\n") ||
value.includes("\r")
) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
区切り文字・ダブルクォート・改行のいずれかを含む場合だけクォートし、内部のダブルクォートは "" に二重化します。これを共通ユーティリティ (src/utils/csvParse.ts) に切り出し、CSV系の全ウィジェットで共有する形にしました。実装当初はこのエスケープが漏れていて、JSONPathのフィルタ式にダブルクォートが含まれるサンプルデータで、CSVが静かに壊れるというバグを踏んでいます。
パース側も同じファイルに実装しています。
export function parseRow(row: string, delim: string): string[] {
const fields: string[] = [];
let cur = "";
let inQ = false;
for (let i = 0; i < row.length; i++) {
const ch = row[i];
if (ch === '"') {
if (inQ && row[i + 1] === '"') {
cur += '"';
i++;
} else {
inQ = !inQ;
}
} else if (ch === delim && !inQ) {
fields.push(cur);
cur = "";
} else {
cur += ch;
}
}
fields.push(cur);
return fields;
}
区切り文字も、1行目の内容からクォート内をカウント対象から除外しつつ自動判定するようにしていて(カンマ・タブ・セミコロン・パイプに対応)、ユーザーがどんな形式のCSVを貼り付けても、ある程度自動で吸収できるようにしています。
おわりに
JSONPath自体は jsonpath-plus に任せられますが、「複数マッチをどう配列として統一的に扱うか」「比較モードをどう設計するか」「CSVエスケープをどこまで厳密にやるか」といった周辺の設計判断のほうが、実際には手間がかかりました。特にCSVエスケープは、地味ですが直接ユーザーの検証結果の信頼性に関わる部分なので、ここは共通ユーティリティとして丁寧に作り込む価値があったと思っています。
ツールはこちらから試せます: https://tools.torinoa.com/tools/json-expected-value-validator/