JSON差分でタイムスタンプとIDを無視する方法(jqなしで)
RESTエンドポイントに新しいフィールドを追加する。スナップショットテストが赤くなる——フィールドが変わったからではなく、createdAt、requestId、traceId が記録されたスナップショットと再実行の間で変化したからだ。10分かけて差分がノイズだと確認し、テストを黙らせて先に進む。これがチームメンバー全員に、すべてのビルドで、毎週繰り返される。
問題はテストフレームワークではない。問題は、生のJSON差分がすべてのフィールドを同等に扱うことだ。ローテーションするUUIDは、意味的な情報を持つフィールドと構造的に同一だ。「このパスを無視する」という手段がなければ、差分の対象にはサーバーが埋め込むすべてのタイムスタンプ、すべての自動採番ID、すべてのリクエストごとの相関トークンが含まれる——いずれもAPIコントラクトが変わったかどうかを反映しない。
このガイドでは、そのノイズを源流で抑制するためのツールとパターンを解説する。JSON Pointerのパス構文、配列に対するワイルドカードパターン、キー名ベースのマッチング用TypeScriptヘルパー、Vitestスナップショット統合、そして実際に意味のある差分が含まれるときだけ非ゼロで終了するCIスクリプトまでを扱う。
「ノイズの多い差分」問題——無関係な変更でCIが失敗する理由
典型的なAPIリグレッションワークフローを考えてみよう。GET /orders/42 のレスポンスを記録する:
{
"id": "ord_9f8e7d6c",
"status": "shipped",
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-01T12:34:56Z",
"requestId": "req_abc123",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
このレスポンスをスナップショットに記録する。2週間後、同僚が shippingCarrier フィールドを追加してテストスイートを実行する。差分には6行の変更が表示されるが、4行はサーバー側でローテーションしたタイムスタンプとIDだ。唯一意味のある変更(新しいフィールド)はノイズに埋もれている。同僚は何も壊れていないと確認し、スナップショットを更新する。レビューは誰も機能的な差分を実際に読まずに進んでいく。
これを100のエンドポイントを持つマイクロサービスメッシュ全体に掛け合わせてみよう。すべてのビルドで、CIは requestId、traceId、version、etag、x-request-start、serverTime を含むスナップショットファイルの差分を取る。レビュアーはスナップショットの差分に麻痺し、読まずに承認するようになる。リグレッション検知のシグナルが崩壊する。
解決策はスナップショットテストをやめることではない。差分を実行する前にノイズを除去することだ。
ノイズの定義:タイムスタンプ、UUID、自動ID、ハッシュ
変化するフィールドがすべてノイズなわけではない。processing から shipped に変わる status はシグナルだ。問題は、設計上ローテーションするため構造的にリグレッション情報を持てないフィールドはどれかということだ。
| カテゴリ | フィールド名の例 | 変化する理由 |
|---|---|---|
| タイムスタンプ | createdAt、updatedAt、deletedAt、lastSeen、expiresAt | サーバークロック、リクエスト時刻 |
| リクエスト相関 | requestId、traceId、spanId、correlationId | リクエストごとに生成 |
| 自動生成ID | id、uuid、nonce、idempotencyKey | 挿入のたびにUUID v4 / ULID / KSUID |
| ETag / キャッシュトークン | etag、eTag、cacheKey、version(自動採番の場合) | コンテンツハッシュまたはシーケンス |
| ビルドメタデータ | buildHash、deployId、serverVersion、hostname | デプロイ時に設定 |
| ページネーションカーソル | nextCursor、prevCursor、pageToken | エンコードされた状態、不透明 |
このテーブルのフィールドは無視リストの候補だ。実際のドメイン状態をエンコードするフィールド——status、amount、userId、items——は絶対にリストに入れてはならない。
目標は、コードに存在し、PRでレビューされ、セキュリティ要件が変わったときに監査される無視リストだ。誰かがローカルで書いてコミットし忘れた一度きりの jq パイプラインではない。
JSON Pointer (RFC 6901) 構文入門
JSON Pointerは RFC 6901 で定義され、JSON Patch (RFC 6902) および多くの構造化差分ツールで使用されるアドレッシングスキームだ。ファイルシステムのパスのように見え、JSONドキュメント内の特定の場所を指定する。
/ → ルートドキュメント
/foo → オブジェクトキー "foo"
/foo/bar → "foo" の中のネストされたキー "bar"
/items/0 → 配列 "items" の最初の要素
/items/0/name → 最初の要素内のキー "name"
特殊文字を扱う2つのエスケープシーケンス:
~0はリテラル~を表す~1はリテラル/を表す
つまりキー a/b は /a~1b としてアドレス指定し、キー a~b は /a~0b としてアドレス指定する。
JSON Pointerは JSONPath(jq、OpenAPIで使用)とは重要な点で異なる:ちょうど1つの場所をアドレス指定する——ワイルドカードなし、再帰的な下降なし、フィルター式なし。RFC 6901 は厳格でシンプルだ。トレードオフとして、すべての配列要素にまたがってフィールドを無視するには、仕様をワイルドカード構文で拡張するツールか、プログラム的にポインタパスを反復処理するかのどちらかが必要になる。
JSON Patchの操作で path フィールドを見かけるとき([{"op":"replace","path":"/status","value":"shipped"}])、それらは RFC 6901 ポインタだ。fast-json-patch や rfc6902 のようなツールはこの記法をネイティブに使用する。
ワイルドカードパターン:配列に対する /users/*/lastSeen
RFC 6901 はワイルドカードを定義していない。しかし実用的な差分ツールのほとんどは、単一セグメントのワイルドカードで仕様を拡張している:* は / を越えずに任意の1パスセグメントにマッチする。
拡張JSON Pointerパターンのルール(Go Tools JSON差分ツールで実装されているもの):
*は任意の単一パスセグメントにマッチする(配列インデックスまたはオブジェクトキー)*は/を越えない——再帰的なグロブではない- キー名のリテラルのアスタリスクにマッチするには
\*とエスケープする **(ダブルスター再帰下降)はサポートされていない——明示的なパスを使う
例:
/users/*/lastSeen → /users/0/lastSeen、/users/1/lastSeen などにマッチ
/orders/*/updatedAt → orders配列のすべての要素の updatedAt にマッチ
/*/requestId → すべてのトップレベルオブジェクトキーの requestId にマッチ
/data/items/*/id → ネストされた items 配列のすべての要素の id にマッチ
単一レベルの制約は重要だ。/users/*/profile/*/tag は有効で、ちょうど2レベルのワイルドカード下降にマッチする。/users/0/tag(浅すぎる)や /users/0/profile/0/meta/tag(深すぎる)にはマッチしない。
典型的なREST APIレスポンスの実用的な無視リスト:
/requestId
/traceId
/createdAt
/updatedAt
/data/*/createdAt
/data/*/updatedAt
/data/*/id
/pagination/nextCursor
これで意味的なフィールドに触れることなく、一般的なケースをカバーできる。
ライブデモ:Stripeウェブフックペイロードで createdAt を無視する
Stripeウェブフックは具体的な例だ。すべてのイベントエンベロープには created(Unixタイムスタンプ)、id(イベントID)、request.id、request.idempotency_key が含まれる——いずれも呼び出しごとにローテーションし、リグレッションテストには不要だ。
生のウェブフックペイロード:
{
"id": "evt_1OqLmn2eZvKYlo2C9Pt1a4X7",
"object": "event",
"created": 1714819200,
"type": "payment_intent.succeeded",
"request": {
"id": "req_Bc7dEfGhIj",
"idempotency_key": null
},
"data": {
"object": {
"id": "pi_3OqLmn2eZvKYlo2C0Pt1a4X8",
"amount": 2000,
"currency": "usd",
"status": "succeeded"
}
}
}
この形状に対する無視リスト:
/id
/created
/request/id
/request/idempotency_key
/data/object/id
これらの無視を適用すると、差分の対象は type、data/object/amount、data/object/currency、data/object/status だけになる——決済動作が変わったかどうかを実際にエンコードするフィールドだ。
ビフォアとアフターの両ペイロードをJSON差分ツールに貼り付け、無視パスを入力すると、JSON Patch出力には意味のある操作だけが残る。jqパイプラインも前処理スクリプトも不要だ。
未知のキーに対するRegexベースのフィールドマッチング
JSON Pointerパスは構造を事前に知っている場合に機能する。2つのシナリオでは機能しない:動的なオブジェクトキー(UUIDキーを持つマップ、ユーザーごとのデータブロブ)と「名前が Id で終わるフィールド」のようなパターンだ。
こういった場合、Regexベースのキーマッチングが必要になる。以下は、JSONツリーを走査してキー名がパターンにマッチするすべてのポインタパスを収集するTypeScriptヘルパーだ:
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
function findPathsByKeyPattern(
value: JsonValue,
pattern: RegExp,
currentPath = ""
): string[] {
const paths: string[] = [];
if (Array.isArray(value)) {
value.forEach((item, i) => {
paths.push(...findPathsByKeyPattern(item, pattern, `${currentPath}/${i}`));
});
} else if (value !== null && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
const encodedKey = key.replace(/~/g, "~0").replace(/\//g, "~1");
const childPath = `${currentPath}/${encodedKey}`;
if (pattern.test(key)) {
paths.push(childPath);
}
paths.push(...findPathsByKeyPattern(child, pattern, childPath));
}
}
return paths;
}
// 使い方:キー名が "Id" または "At" で終わるすべてのパスを収集
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]
これにより、手動でメンテナンスする文字列のセットではなく、実際のレスポンス形状から派生した動的な無視リストが得られる。注意点:userId や orderId のような正当なフィールドが Id で終わる場合、パターンを絞り込むか、その結果からそれらのパスを除外する必要がある。名前パターンは明示的なパスリストの代替ではなく——すべてのパスを手動でメンテナンスするのが非現実的な大規模APIに対する補完手段だ。
無視パスを除去したJSON Patch出力
JSON Patch (RFC 6902) として表現されたJSON差分は操作のリストだ:
[
{ "op": "replace", "path": "/updatedAt", "value": "2026-05-04T09:00:00Z" },
{ "op": "replace", "path": "/status", "value": "shipped" },
{ "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]
無視パスのフィルタリングとは、path が無視リストのいずれかのエントリにマッチする(ワイルドカードを考慮した)操作を削除することを意味する。フィルタリング後の出力:
[
{ "op": "replace", "path": "/status", "value": "shipped" },
{ "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]
2つの重要な注意点:
-
非ラウンドトリップ警告。 フィルタリングされたパッチを適用してターゲットドキュメントを再構築することはできない。ノイズ以外の操作のみをソースに適用すると、タイムスタンプの更新が欠けたドキュメントになる。ドキュメントの再構築にはフルパッチを保存し、差分の比較とアラートにのみフィルタリングされたパッチを使う。
-
順序の感度。 JSON Patch操作は順序付けられている。前の位置を参照する move や copy 操作を含むパッチの途中から操作をフィルタリングすると、無効なパッチになる可能性がある。フィルタリングされたパッチを適用する必要がある場合は、まずフルパッチを適用し、その結果を期待する状態に対して再差分する。
JSON差分ツールはフルパッチとフィルタリングされたビューの両方を出力するので、どの操作を抑制するか決める前に生の差分を確認できる。
スナップショットテストレシピ(Jest / Vitest)
Vitestのスナップショットシステムは値を文字列にシリアライズし、再実行時に差分を取る。デフォルトのシリアライザはノイズのあるものを含むすべてのフィールドをキャプチャする。解決策は、スナップショット作成前に無視パスを除去するカスタムシリアライザだ。
// tests/setup/json-diff-serializer.ts
import { expect } from "vitest";
const IGNORE_PATHS = new Set([
"/requestId",
"/traceId",
"/createdAt",
"/updatedAt",
"/data/id",
]);
function stripPaths(obj: unknown, path = ""): unknown {
if (Array.isArray(obj)) {
return obj.map((item, i) => stripPaths(item, `${path}/${i}`));
}
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const childPath = `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`;
if (!IGNORE_PATHS.has(childPath)) {
result[key] = stripPaths(value, childPath);
}
}
return result;
}
return obj;
}
expect.addSnapshotSerializer({
test: (val) => val !== null && typeof val === "object" && !Array.isArray(val),
print: (val, serialize) => serialize(stripPaths(val)),
});
vitest.config.ts に登録する:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: ["./tests/setup/json-diff-serializer.ts"],
},
});
スナップショットはこのようになる:
it("returns order with shipping carrier", async () => {
const result = await getOrder("ord_42");
expect(result).toMatchSnapshot(); // createdAt、requestId などは除去済み
});
スナップショットファイルには安定したフィールドのみが含まれる。タイムスタンプが変化した後にスイートを再実行しても差分はゼロになる。shippingCarrier フィールドが追加されると、スナップショットの差分にはその1つの変更だけが表示される。またJSON差分ツールを使って、スナップショットを更新する前に2つの記録されたレスポンス間で何が変わったかを手動で確認することもできる。
CI統合:意味のある差分があるときだけビルドを失敗させる
最終目標:参照レスポンスをダウンロードし、ライブエンドポイントにアクセスし、2つを比較し、ノイズを除去し、意味のある操作が残った場合のみ非ゼロで終了するCIステップを作ること。
// scripts/api-regression-check.ts
import { applyPatch, compare } from "fast-json-patch";
import { readFileSync } from "fs";
const IGNORE_PATTERNS = [
/^\/requestId$/,
/^\/traceId$/,
/^\/createdAt$/,
/^\/updatedAt$/,
/^\/data\/\d+\/createdAt$/,
/^\/data\/\d+\/updatedAt$/,
/^\/data\/\d+\/id$/,
];
function isMeaningfulOp(op: { path: string }): boolean {
return !IGNORE_PATTERNS.some((re) => re.test(op.path));
}
async function main() {
const reference = JSON.parse(readFileSync("reference.json", "utf-8"));
const response = await fetch(process.env.API_URL + "/orders").then((r) => r.json());
const ops = compare(reference, response);
const meaningfulOps = ops.filter(isMeaningfulOp);
if (meaningfulOps.length > 0) {
console.error("Meaningful diff detected:");
console.error(JSON.stringify(meaningfulOps, null, 2));
process.exit(1);
}
console.log(`Clean diff (${ops.length} noise ops suppressed).`);
}
main().catch((e) => { console.error(e); process.exit(1); });
GitHub Actionsのステップ:
- name: API regression check
env:
API_URL: ${{ secrets.STAGING_API_URL }}
run: npx tsx scripts/api-regression-check.ts
fast-json-patch の compare 関数は RFC 6902 操作を返す。フィルターはVitestシリアライザと同じRegexアプローチを使って path 文字列に対して実行される。ノイズのみが変化した場合はスクリプトは0で終了し、意味のあるフィールドに差分がある場合は1で終了する。
ワイルドカードマッチング(/data\/\d+\/updatedAt の代わりに /data/*/updatedAt をカバーする)には、Regexリストをglobからregexへのコンバータに置き換えるか、JSON差分ツールが受け付ける無視リスト形式を使用してエクスポート経由でパイプラインに統合する。
無視すべきでない場合:セキュリティ上重要なフィールド、監査証跡
無視リストは強力だ。しかし規律なく適用すると危険でもある。ノイズのように見えてもセキュリティ上重要な情報を持つフィールドが存在する。
無視してはならないもの:
- 監査ログや財務記録の
createdAt。トランザクションのタイムスタンプが間違っている場合、それはバグであってノイズではない。監査テーブルはタイムスタンプの異常を通じた改ざんを検出するために存在する。 - 決済インテント、請求書、規制上の記録の
idフィールド。決済コンテキストでローテーションするIDは重複請求やルーティングミスのイベントを示す可能性がある。 - 楽観的同時実行システムの
versionやetag。2つのプロセスが同じバージョンで書き込もうとすると一方が失敗する。versionフィールドは装飾的なものではない。 - 分散システムの障害をデバッグする際の
traceIdやspanId。これらはスナップショットテストではノイズだが、本番インシデントの分析ではシグナルだ。 - セキュリティチームが監視のためにフラグを立てたフィールド——SIEMルール、コンプライアンス要件、データ保持ポリシーがこれらのフィールドの差分可視性に依存している可能性がある。
正しいモデルは、無視リストをコードレビューされた許可リストとして扱うことだ:名前付き定数またはコンフィグファイルで、リポジトリにチェックインされ、各パスがリストにある理由を説明するコメント付きで。無視リストへの変更は、本番設定の変更と同じレビューを必要とするべきだ。
// 明示的で監査可能、変更にはPRレビューが必要
export const SNAPSHOT_IGNORE_PATHS = [
"/requestId", // リクエストごとのUUID、意味的な内容なし
"/traceId", // OpenTelemetryトレースID、リクエストごとにローテーション
"/createdAt", // サーバークロック、本番フィクスチャでは安定
// "/amount" // 絶対に無視しない——財務フィールド
] as const;
フィールドを無視リストに入れるべきか迷った場合は、差分に残す方向でエラーする。偽陽性(差分内のノイズ)は数秒のレビューコストだ。偽陰性(パスが無視されたために見逃されたリグレッション)はユーザーへのコストになる。
まとめ
生のJSON差分がノイズだらけなのは、サーバーレスポンスが設計上ローテーションするフィールドを埋め込んでいるからだ。JSON Pointer (RFC 6901) 構文は精密なアドレッシングスキームを提供する。ワイルドカードパターン(/data/*/createdAt)はカスタムのトラバーサルコードを書かずにそれを配列に拡張する。Regexベースのキーマッチングは、パスが事前にわからない動的な構造を扱う。無視パスを除去したJSON Patch (RFC 6902) 出力はCIにクリーンなシグナルをもたらす。Vitestのカスタムシリアライザは同じロジックをスナップショットテストに適用する。そしてコードレビューされた規律ある無視リストは、ノイズの抑制が実際のリグレッションを誤って飲み込まないことを保証する。
JSON差分ツールで実際に試してみよう——2つのJSONレスポンスを貼り付け、無視パスを入力すると、意味のある操作だけが表示される。差分前にminifiedされたレスポンスを整形するにはJSONフォーマッターを使おう。