Quick answer: Test JSON query values at four boundaries: serialization layers, special-character preservation, parameter cardinality, and application acceptance. Use a structured URL builder, parse the query once according to its contract, then parse and validate the JSON. Repeatedly decoding until the text looks familiar can change legitimate data.
A search filter containing C++ works in one client and becomes C in another. The JSON syntax is fine; the query construction lost information before JSON parsing began. Small round-trip fixtures can expose that problem without blaming the API's filter logic.
These checks apply when an endpoint explicitly accepts a JSON query value. They do not recommend moving every request body into a URL. Our JSON in REST APIs guide covers the broader request contract; the JSON escape-character guide explains the separate string-syntax layer.
1. Does one serialization cycle recover the exact JSON value?
Serialize the JSON value once, pass that unencoded text to the query builder, and verify that query extraction followed by JSON parsing restores the original value. Add a deliberately pre-encoded negative control. It should reveal an extra representation layer rather than encourage the receiver to decode repeatedly until a test happens to pass.
The Node.js URL documentation describes its WHATWG URL and URLSearchParams APIs. Use those structured operations instead of assembling a query with string concatenation. The example's parameter is named filter, but that name and the endpoint's expected JSON shape remain application decisions.
import assert from 'node:assert/strict';
const original = { category: 'books', limit: 3 };
const jsonText = JSON.stringify(original);
const url = new URL('https://example.com/search');
url.searchParams.set('filter', jsonText);
const received = new URL(url.href).searchParams.get('filter');
assert.equal(received, jsonText);
assert.deepEqual(JSON.parse(received), original);
const extraLayer = new URLSearchParams({
filter: encodeURIComponent(jsonText)
});
assert.notEqual(extraLayer.get('filter'), jsonText);
assert.throws(() => JSON.parse(extraLayer.get('filter')), SyntaxError);
Keep all three representations in your test: the complete URL, the extracted JSON text and the parsed object. A failed assertion then identifies which boundary changed the data. Logging only the final object cannot show an unnecessary encoding layer that a later workaround silently removed.
MDN's encodeURIComponent reference describes component encoding. The function is useful when its caller owns that representation layer; it is not a required preliminary step before every parameter API. Check whether the next function expects an already serialized component or an ordinary value.
Some APIs define additional service-specific decoding behavior. For example, Docebo's API-browser documentation describes a particular plus-sign convention for direct calls. Treat such instructions as endpoint-specific. A workaround copied from another service can create double encoding in a standard URLSearchParams round trip.
Store the expected decoding stages in the client integration documentation. Name which layer the framework has already removed before application code runs. That gives maintainers a concrete reason to reject a convenience helper that unconditionally decodes every incoming string again, even if simple ASCII samples continue to work.
Test the helper against an existing URL with an unrelated parameter. Adding a JSON filter should preserve the other parameter's decoded value and leave the origin and path unchanged. This catches a builder that accidentally replaces the entire query or treats a complete URL as a parameter name. Keep construction tests separate from network tests so failures identify the responsible code rather than depending on a remote service being available.
2. Are spaces, plus signs, delimiters and Unicode preserved?
Include literal plus signs, spaces, ampersands, hashes, percent signs and non-ASCII text in one round-trip fixture. Form-style query serialization gives these characters different representations from their plain-text appearance. Assert decoded value equality rather than requiring one cosmetic URL spelling, and verify that data characters never become additional parameters or a fragment.
The WHATWG URL Standard defines form-style parameter serialization and parsing. Wikipedia's percent-encoding background supplies terminology, but the standard and runtime APIs determine the behavior tested here. A plus in a parameter value and a plus in serialized query syntax are different contexts.
const tricky = { q: 'C++ & tea #1 100% 馃榾', literal: '%2B' };
const specialUrl = new URL('https://example.com/search');
specialUrl.searchParams.set('filter', JSON.stringify(tricky));
assert.equal(specialUrl.hash, '');
assert.equal([...specialUrl.searchParams.keys()].length, 1);
assert.deepEqual(
JSON.parse(new URL(specialUrl.href).searchParams.get('filter')),
tricky
);
assert.equal(new URLSearchParams('q=C++').get('q'), 'C ');
assert.equal(new URLSearchParams({ q: 'C++' }).get('q'), 'C++');
The literal percent sequence is a useful negative control. After correct query extraction, the field should still contain the literal characters percent, two and B. Another decoding pass would reinterpret data that the sender did not ask the application to decode. Include similar fixtures when users can search for encoded-looking text.
MDN's URLSearchParams guide explains percent encoding and plus handling. It also distinguishes creating parameters from raw values from parsing serialized query text. Prefer passing ordinary strings through set or append rather than interpolating user text into a constructor string that already has query syntax.
After extracting the JSON text, use the JSON Viewer to inspect its decoded structure during debugging. The viewer parses a JSON document; it does not URL-decode a request or verify your gateway's query behavior. Preserve the original URL separately so inspection does not erase evidence about the failing transport layer.
Test equivalent characters across the actual client and server runtimes you support. A successful Node.js round trip establishes the shown local behavior, not universal agreement with every framework's query parser. Run this same fixture through a request handler before releasing a client helper used across multiple services.
For international input, preserve the exact decoded string rather than normalizing the expected fixture just to make a comparison pass. A normalization policy can be valid for a particular search field, but it belongs to that field's contract. The transport fixture should first establish that the original value survived. Otherwise a lossy query builder and a forgiving comparator can agree while concealing the change from both client and server developers.
3. Does the receiver reject missing, empty or repeated filter parameters?
Define exactly how many JSON filter parameters the endpoint accepts, then test missing, empty and repeated occurrences separately. A convenient get call can conceal duplicates by returning only one value. Cardinality checking should happen before JSON parsing so ambiguous request syntax does not silently choose whichever filter one framework happens to prefer.
The example contract accepts exactly one nonempty filter value. It does not treat omission as an empty object, and it rejects repeated values even when they happen to be identical. Other endpoints can choose another policy, but that choice must be shared by the client, gateway and application parser.
function readSingleFilter(searchParams) {
const values = searchParams.getAll('filter');
if (values.length !== 1 || values[0] === '') {
throw new Error('one nonempty filter required');
}
return JSON.parse(values[0]);
}
assert.deepEqual(readSingleFilter(url.searchParams), original);
for (const query of ['', 'filter=', 'filter', 'filter=%7B%7D&filter=%7B%7D']) {
assert.throws(() => readSingleFilter(new URLSearchParams(query)));
}
assert.throws(() => readSingleFilter(new URLSearchParams({ filter: '{bad}' })));
The JSON.parse documentation describes parsing the extracted text and reporting invalid JSON. Keep this syntax failure distinct from a missing parameter or a duplicate parameter. Those errors occur at different boundaries and suggest different corrections to the caller.
Do not collapse the query into a plain object before checking duplicates if that operation discards repeated names. Likewise, a test that starts with a hand-built dictionary cannot prove the HTTP query parser preserves cardinality. Exercise the serialized query and the parser that the deployed route actually uses.
Check mutation behavior on the client too. If a reusable URL already contains filter, setting its value replaces prior values under the parameter API, while appending creates another occurrence. A helper intended to replace a search filter should have a fixture showing it leaves one occurrence after repeated calls. That catches stale parameters accumulated by pagination or navigation code.
For defaults, distinguish no parameter from a parameter whose JSON value is null. Neither the query layer nor JSON parsing decides whether null means no filtering, an invalid shape or a specific business operation. Put that decision in the endpoint's value contract and give it an explicit accepted or rejected fixture.
Include duplicate occurrences with different values in the integration suite. Identical duplicates are a useful starting point, but they do not reveal whether two infrastructure layers select different winners. Send one narrow filter and one broader filter, then confirm the application rejects the ambiguity before choosing either. Do not infer gateway behavior from the order returned by a standalone URL parser used only in the unit test.
4. Does the decoded value pass the endpoint contract before execution?
Validate the parsed JSON shape and deployment-specific request size before running the operation. A well-encoded parameter can contain valid JSON with the wrong type, unknown fields or unacceptable values. Test both an accepted request and rejected requests, and assert that rejection prevents the search, write or downstream call from executing.
The small gate below accepts one exact filter shape. It measures the serialized URL using an explicitly supplied byte budget for this fixture, not a universal web limit. In production, configure limits at the earliest relevant boundary and test the full proxy-to-handler path as well as the application helper.
function acceptSearch(inputUrl, maxUrlBytes) {
if (Buffer.byteLength(inputUrl, 'utf8') > maxUrlBytes) return null;
const value = readSingleFilter(new URL(inputUrl).searchParams);
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
if (Object.keys(value).sort().join(',') !== 'category,limit') return null;
if (typeof value.category !== 'string' || value.category.length === 0) return null;
if (!Number.isInteger(value.limit) || value.limit < 1 || value.limit > 20) return null;
return value;
}
const budget = Buffer.byteLength(url.href, 'utf8');
assert.deepEqual(acceptSearch(url.href, budget), original);
assert.equal(acceptSearch(url.href, budget - 1), null);
const wrongShape = new URL('https://example.com/search');
wrongShape.searchParams.set('filter', 'null');
assert.equal(acceptSearch(wrongShape.href, 1000), null);
Keep parser exceptions in the request error-handling path; this helper deliberately does not turn malformed syntax into an accepted default. Test that your framework maps the failure to its documented client-error response without proceeding to the protected operation. A returning-null convention also needs a caller that treats null as rejection rather than an invitation to continue unfiltered.
Use a synthetic operation counter in integration tests. Submit an accepted request, then oversized, malformed and wrongly shaped requests, and verify only the accepted request increments the counter. If the route sends an event or queries another service before validating, a correct helper alone cannot undo that earlier side effect.
Keep the size measurement reproducible. Store the complete serialized request fixture, the configured budget and the expected decision together. A limit measured before query serialization can miss the expansion introduced by encoded characters. A limit measured only after the handler receives the request cannot protect an earlier gateway. The local byte assertion checks the named helper; deployment tests establish which layer actually rejects the request first.
Avoid putting credentials or sensitive filter contents into shareable URLs. Request URLs can enter operational systems outside the final response body; review the application's actual logging and retention behavior. The JSON redaction guide covers inspection practices, while encoding itself only changes representation and does not encrypt the value.
The examples were executed on Node.js 24.22.1. They demonstrate local serialization and acceptance fixtures, not a complete browser-to-proxy compatibility test. Keep these four checks with the client helper and run their boundary cases through the deployed route. A stable contract identifies the encoding stages, preserves characters, rejects ambiguity and validates values before execution.