0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSONPathとCSVでテストケースを大量検証するツールを作った(jsonpath-plus)

0
Posted at

はじめに

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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?