Quick answer: Before using JSON Pointer paths for API updates, test four boundaries: the root versus an empty key, escaped tokens and transport decoding, array index rules, and existing values versus missing targets. Run those checks against the implementation your application actually uses, not just a helper that accepts the happy path.
A path can look perfectly reasonable and still address the wrong value. These checks turn that ambiguity into small, repeatable failures before a write reaches stored data. For background on the data format, Wikipedia's JSON overview explains objects and arrays; the technical pointer rules come from the primary sources linked below.
1. Does your root test distinguish an empty pointer from an empty key?
Test the empty pointer and the single slash as different inputs. The empty pointer selects the entire JSON document; the slash selects a member with an empty name. Your API should preserve that distinction through request parsing, validation, logging, and any code that decides whether an update replaces the root.
Start with a deliberately awkward fixture rather than a realistic customer record. Give the root object an empty key, a normal key, and a nested object that also has an empty key. Those values should be visibly different. If all three contain the same placeholder, an incorrect resolver can appear to pass.
The nlohmann JSON Pointer documentation demonstrates root and nested access, and distinguishes checked access from APIs with fallback or creation behavior. That difference matters when choosing the method under test: an existence check and a method that creates intermediate structures answer different questions.
This standalone JavaScript check tests token boundaries, not a complete resolver. Save each JavaScript block as an .mjs file and run it with Node.js. The complete read-only resolver later in this guide puts the boundaries together.
import assert from "node:assert/strict";
const tokens = pointer => pointer === "" ? [] : pointer.slice(1).split("/");
assert.deepEqual(tokens(""), []);
assert.deepEqual(tokens("/"), [""]);
assert.deepEqual(tokens("/settings/"), ["settings", ""]);
assert.deepEqual(tokens("/ "), [" "]);
Treat an omitted request property separately from a supplied empty string. A default such as pointer || "/" destroys information at exactly the point where you need it most. Decide whether omission is an error, an application default, or a different operation, and assert that contract at the request boundary.
Also inspect the UI that collects paths. A text field can show an empty input without explaining whether it means root or “not entered yet.” Prefer an explicit root selection or a clear preview of the selected scope. Tests should submit the same payload shape the browser sends, including the difference between an absent property and an empty string.
Use the JSON Viewer to inspect the fixture's structure before debugging the path. It provides a collapsible tree and copies JSONPath expressions; those copied expressions are not JSON Pointer strings. The viewer is useful for confirming which keys exist, not for proving a pointer resolver or update policy is correct.
Record the submitted pointer with JSON string serialization in test failure messages. That makes spaces and empty strings visible without silently trimming them. Keep fixture values synthetic and non-sensitive so a failed assertion does not spill a production response into continuous integration logs.
Add one integration case for an empty key beneath a normal parent. It catches code that preserves the first slash but drops a trailing empty token during splitting or filtering. Make the expected result a scalar that cannot be confused with the parent object. Assert the target's value and type together, then inspect the serialized request if either assertion fails.
2. Does your escaping test preserve literal keys through every representation?
Test keys containing slashes, tildes, percent signs, and literal escape-looking text. Build each pointer from individual keys, then verify it resolves to the intended member. Keep JSON string escaping, JSON Pointer token escaping, and URI fragment decoding separate so one decoding step cannot accidentally perform the work of another.
According to RFC 6901's evaluation rules, decoding replaces ~1 before ~0. The token ~01 therefore becomes the literal text ~1, not a slash. Invalid tilde escapes are syntax errors. Put that counterexample beside an ordinary slash key rather than testing escaping with only one round trip.
import assert from "node:assert/strict";
const encode = key => key.replace(/~/g, "~0").replace(/\//g, "~1");
function decode(token) {
if (/~(?:[^01]|$)/u.test(token)) throw new Error("Invalid escape");
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
for (const key of ["a/b", "m~n", "~1", "a%2Fb", " ", "quote\""]) {
assert.equal(decode(encode(key)), key);
}
assert.equal(decode("~01"), "~1");
assert.equal(decode("a%2Fb"), "a%2Fb");
assert.throws(() => decode("bad~2"));
assert.throws(() => decode("bad~"));
const pointer = "/" + encode("a/b");
assert.equal(JSON.parse(JSON.stringify(pointer)), pointer);
assert.equal(decodeURIComponent("/a%252Fb"), "/a%2Fb");
For the transport boundary, write down the representation your endpoint accepts. A JSON request property containing a plain pointer is not the same input format as a URI fragment. JsonPointer.Net's primary documentation describes both pointer syntax and the fragment form. Test each supported representation independently; reject unsupported ones instead of guessing from whatever characters happen to appear.
Include two neighboring members in your fixture: one named a/b, another named a%2Fb. They make accidental percent-decoding visible. The last assertion above demonstrates one decoding pass, not a general-purpose fragment parser. Production fragment handling must follow the surrounding protocol, including its encoding rules and declared fragment semantics.
A useful failure report shows the original member name, the serialized request, and the pointer after boundary parsing. Keep those as separate fields. If the report collapses everything into a single “path” string, the test may identify the wrong layer as broken and encourage another compensating decode.
For a path-building function, add generated round-trip cases alongside these readable examples. Generate individual keys first, encode each token, and only then join them with separators. Never encode a complete already-joined path as though it were one key. Keep the small named regressions even after adding randomized tests, because a reproducible counterexample is easier to review than a random seed alone.
Review test names with the engineer maintaining the request adapter. A name such as “escaped path succeeds” hides the important detail. Prefer names that identify the representation and expected member: a plain pointer containing a literal percent sequence, for example. That naming convention makes a failed transport test actionable without requiring someone to reconstruct the entire encoding pipeline from a stack trace.
3. Does your array test reject indexes that merely look numeric?
Test array indexes as strings with a specific grammar, not as values that JavaScript can coerce into numbers. Accept zero and valid in-range decimal indexes, but reject leading zeros, signs, fractions, and whitespace. Separately test append behavior in the update operation that supports it rather than treating append as ordinary lookup.
RFC 6901 defines array indexes without leading zeros and identifies - as a nonexistent position after the array. A concrete-value lookup fails there. RFC 6902's add operation supplies the append meaning for JSON Patch. Do not transfer that operation-specific behavior into every pointer API.
import assert from "node:assert/strict";
function readableIndex(token, length) {
return /^(0|[1-9][0-9]*)$/u.test(token)
&& Number.isSafeInteger(Number(token))
&& Number(token) < length;
}
for (const token of ["00", "01", "+1", "-1", "1.0", " 1", "1 ", "-", "2"]) {
assert.equal(readableIndex(token, 2), false, JSON.stringify(token));
}
assert.equal(readableIndex("0", 2), true);
assert.equal(readableIndex("1", 2), true);
assert.equal(readableIndex("0", 0), false);
Run the same token against an object fixture too. An object member named 01 is a string key, not an array index with a leading zero. Rejecting that key globally would fix one class of bugs by introducing another. The JSON array versus object guide is a useful refresher when the same endpoint accepts multiple resource shapes.
Test the boundaries around the current length: an empty array, its first readable element after insertion, its last readable element, and the next position. Use the length observed by the operation, not a hard-coded maximum copied from a sample response. Otherwise, the test verifies yesterday's fixture size rather than the application's behavior.
For updates, keep an expected document beside the expected path. An insertion and a replacement can both target a valid index while producing different results. The JSON Patch versus Merge Patch guide explains the format choice; this checklist concentrates on whether the address reaches the intended target.
Finally, change the fixture's ordering and rerun any path derived from an earlier read. A numeric position is not a stable record ID. If your workflow needs to modify a particular item, test identity in the write workflow and protect against intervening changes. The JSON API concurrency tests cover that broader boundary. Passing an index grammar test alone cannot prevent a valid path from reaching the wrong record after a reorder.
4. Does your lookup test distinguish missing targets from existing falsey values?
Require an explicit success or failure result from pointer lookup. Existing null, false, zero, and empty strings are successful targets, while absent members and traversal through scalar values fail. In JavaScript, check own properties rather than inherited names, and verify that read-only evaluation leaves the fixture unchanged before any update begins.
MDN documents that Object.hasOwn detects directly defined properties even when their values are null or undefined, unlike a truthiness test. It also avoids inherited properties. That makes it a useful building block for a resolver operating on JSON-parsed data, though it is not an authorization policy.
The following read-only teaching implementation accepts plain pointer strings and JSON-parsed values. It deliberately throws on lookup failure. It does not implement URI fragments, mutation, duplicate-key detection before parsing, access control, or limits for hostile input. Test your chosen production library against the same cases rather than assuming this example replaces its review.
import assert from "node:assert/strict";
function lookup(document, pointer) {
if (typeof pointer !== "string") throw new Error("Pointer must be a string");
if (pointer === "") return document;
if (!pointer.startsWith("/")) throw new Error("Invalid pointer");
const tokens = pointer.slice(1).split("/").map(raw => {
if (/~(?:[^01]|$)/u.test(raw)) throw new Error("Invalid escape");
return raw.replace(/~1/g, "/").replace(/~0/g, "~");
});
let current = document;
for (const token of tokens) {
if (current === null || typeof current !== "object") throw new Error("Scalar parent");
if (Array.isArray(current) && (
!/^(0|[1-9][0-9]*)$/u.test(token)
|| !Number.isSafeInteger(Number(token))
|| Number(token) >= current.length
)) throw new Error("Invalid array target");
if (!Object.hasOwn(current, token)) throw new Error("Missing target");
current = current[token];
}
return current;
}
const document = JSON.parse('{"":"empty","a/b":1,"~1":2,"01":3,"items":[null,false,0,""]}');
const before = JSON.stringify(document);
assert.equal(lookup(document, ""), document);
assert.equal(lookup(document, "/"), "empty");
assert.equal(lookup(document, "/a~1b"), 1);
assert.equal(lookup(document, "/~01"), 2);
assert.equal(lookup(document, "/01"), 3);
for (const [index, expected] of [null, false, 0, ""].entries()) {
assert.equal(lookup(document, `/items/${index}`), expected);
}
for (const pointer of ["/missing", "/toString", "/items/01", "/items/-", "/items/4", "/items/0/x", "/bad~2"]) {
assert.throws(() => lookup(document, pointer), pointer);
}
assert.equal(JSON.stringify(document), before);
Notice that the success assertions inspect the returned value without converting it to a boolean. A wrapper that catches errors and returns null would erase the distinction again, even if the underlying resolver is correct. Include the wrapper in integration tests, especially when a service maps library exceptions into HTTP responses.
Keep malformed syntax and unresolved targets as separate failure categories in your test data, even when the public endpoint intentionally gives them the same response. The distinction helps maintenance: a parser regression and a changed fixture should not lead to the same investigation. Assert only the public error details your API promises, not an incidental exception message from a dependency.
Keep read permission and write permission separate. A field can exist and still be immutable or unavailable to a caller. After successful lookup, test the operation's authorization, apply it through your update layer, and validate the candidate resource before persistence. Avoid treating a successful pointer lookup as evidence that the whole write is safe.
Finish by running these four groups through the actual request adapter and pinned dependency used in deployment. Save the original fixture, request representation, expected target, and expected failure category together. That gives a future library upgrade a concrete acceptance contract: not “the pointer worked,” but “this exact request selected this exact value without changing the document.”