Quick answer: Test six execution boundaries when serializing JavaScript application objects: included getter reads, replacer timing, exception side effects, mutation during traversal, inherited or non-enumerable toJSON hooks, and output projection. JSON.stringify can execute application code before returning JSON, so a valid final string is only one part of the evidence your API needs.
A response serializer reads a computed property, and suddenly a counter changes or an exception replaces the expected body. The replacer test guide introduces transformation callbacks; these checks focus on property access and execution around serialization.
Run the JavaScript blocks together, in order. They were tested locally with Node.js v24.22.1 using synthetic ordinary objects. No browser matrix, proxy sandbox, or remote endpoint was tested. Wikipedia's serialization overview explains the general representation boundary; the language references below define the JavaScript behavior.
1. Does serialization read the getters the output contract actually includes?
Test an included enumerable getter and a non-enumerable getter on the same ordinary object, then assert both the output and read counters. Default object serialization selects own enumerable string-keyed properties. That selection can execute included accessors, while excluded ordinary properties are not read through the same enumeration path, so visibility and execution need separate assertions.
The getter reference explains that reading an accessor property invokes its function. The JSON.stringify reference describes ordinary property selection. Neither turns a computed application object into an inert data record.
const assert = require("node:assert/strict");
let visibleReads = 0;
let hiddenReads = 0;
const source = {
get total() { visibleReads += 1; return 12; }
};
Object.defineProperty(source, "hidden", {
enumerable: false,
get() { hiddenReads += 1; return "internal"; }
});
assert.equal(JSON.stringify(source), '{"total":12}');
assert.equal(visibleReads, 1);
assert.equal(hiddenReads, 0);
The hidden getter control is deliberately narrow. It does not establish that all non-enumerable behavior is ignored: toJSON lookup is a different operation, covered later. Avoid expanding an observation about ordinary fields into a promise about every property access the serializer might perform.
Keep getters deterministic when they legitimately belong in a response. A getter that reads a changing cache, increments state, or performs expensive computation makes serialization observable beyond the returned string. An assertion on its read count can detect duplicated encoding by a response adapter or logger before that duplication becomes a confusing integration issue.
Also test the plain-data version of the response. If the contract needs a stored total rather than a live computation, construct that value at a deliberate application step. Then assert the transfer object's shape independently from the domain object's accessor behavior, so serialization does not quietly become the place where business calculations happen.
2. Does a replacer discard the value only after its getter has executed?
Test a getter behind a field your replacer function omits, and assert that the getter still ran. SerializeJSONProperty reads the value before invoking the replacer callback. Returning undefined changes the output decision, not the earlier execution. Use a separate controlled projection or appropriate property selection when avoiding a read is part of the requirement.
The ECMAScript property serialization steps define this ordering. A field absent from the emitted JSON is therefore not evidence that the producer never accessed it or triggered its computation.
let secretReads = 0;
const record = {
id: "demo-1",
get secret() { secretReads += 1; return "synthetic-secret"; }
};
const omitted = JSON.stringify(record, (key, value) =>
key === "secret" ? undefined : value
);
assert.equal(omitted, '{"id":"demo-1"}');
assert.equal(secretReads, 1);
secretReads = 0;
assert.equal(JSON.stringify(record, ["id"]), '{"id":"demo-1"}');
assert.equal(secretReads, 0);
The property-list control works for this ordinary fixture because secret is not among the selected names. That is not a blanket guarantee for nested instances with hooks, proxies, or getters on selected properties. A property-list replacer also applies its list to nested ordinary objects, which may unintentionally remove fields their contract needs.
For logging or redaction, distinguish output confidentiality from read avoidance. Omitting a value can prevent it appearing in the string while still running code that reads or computes it. The sensitive-data redaction guide discusses output handling; this test isolates the execution that can precede that output.
Name the assertion according to the actual guarantee. “Secret absent from output” and “secret getter not invoked” are different claims, and one can pass while the other fails. Keeping both visible prevents a future refactor from weakening a read policy while preserving an apparently reassuring JSON snapshot.
3. Does a thrown getter prevent transport without hiding earlier side effects?
Test a getter that throws after another included getter has changed observable state. Assert the original error identity and the earlier effect, then check that a serialization-before-send wrapper never starts transport. A failed stringify call does not return a partial JSON string, but it also does not roll back code that already ran during traversal.
ECMAScript serialization propagates abrupt completion from property access. That failure boundary is different from a network exception, so keep the two paths distinguishable in application diagnostics and tests.
let effects = 0;
let sends = 0;
const failure = new Error("synthetic getter failure");
const failing = {
get first() { effects += 1; return "ready"; },
get second() { throw failure; }
};
function serializeThenSend(value) {
const body = JSON.stringify(value);
if (typeof body !== "string") throw new TypeError("No JSON body");
sends += 1;
return body;
}
assert.throws(() => serializeThenSend(failing), error => error === failure);
assert.equal(effects, 1);
assert.equal(sends, 0);
assert.equal(serializeThenSend({ ok: true }), '{"ok":true}');
assert.equal(sends, 1);
The positive control proves that the wrapper can reach its send boundary. Without it, an implementation that never sends anything could satisfy every failure assertion. The synthetic counter represents transport initiation only; it does not test an HTTP client, response headers, streaming, retries, or delivery to a server.
If a catch handler logs the original object with JSON.stringify again, it can repeat the same getter failure or side effect. Record a safe error category and fixture identifier instead of recursively serializing the object that caused the problem. Test that recovery path separately if your logging stack performs its own serialization.
A retry must also account for the earlier effect. Calling stringify again on the same object reruns the first getter before encountering the same second failure in this fixture. If the computation updates application state, repeated serialization attempts can compound that update even though no request is ever sent. Keep recovery logic outside the serializer's implicit traversal, and test the number of attempts when a wrapper retries failures automatically.
4. Do getter mutations change later values without adding newly created keys?
Test an early getter that updates an existing later field and adds a new field, then compare source state with emitted output. Ordinary object serialization obtains its property list before visiting those values. Later reads can observe mutations to listed properties, while a newly added ordinary property is not automatically appended to that already selected list.
The ECMAScript object serialization algorithm separates property-list selection from per-property serialization. This makes a getter-driven response different from an immutable snapshot, even though execution is synchronous in these fixtures.
const changing = {
get first() {
this.later = "changed";
this.added = "new";
return "start";
},
later: "original"
};
assert.equal(JSON.stringify(changing), '{"first":"start","later":"changed"}');
assert.equal(changing.added, "new");
assert.equal(JSON.stringify(changing),
'{"first":"start","later":"changed","added":"new"}');
The second call has a different property list because added exists before that call begins. A snapshot taken once in a test can miss this repeated-call behavior. Include repeated serialization if retry code, request signing, caching, or logging might encode the same application instance more than once.
Changing enumeration order is not a repair for a getter with business side effects. It may merely move the observed mutation to another field. The key-order tests cover ordering concerns separately; this fixture demonstrates why a stable key sequence does not imply stable values or an unchanged source object.
5. Can inherited or non-enumerable serialization hooks replace the ordinary output?
Test a non-enumerable own toJSON method and an inherited toJSON method independently. Lookup of this hook is separate from enumerating output fields, so either callable can replace the value before normal serialization proceeds. A review that checks only own enumerable data fields can therefore overlook code that determines the complete outgoing shape.
The JSON.stringify toJSON documentation explains replacement behavior. Test hook discovery as well as the shape returned by the hook, especially when application classes or altered prototypes cross the response boundary.
const ownHook = { visible: "original" };
Object.defineProperty(ownHook, "toJSON", {
enumerable: false,
value() { return { visible: "replacement" }; }
});
assert.equal(JSON.stringify(ownHook), '{"visible":"replacement"}');
const prototype = {
toJSON(key) { return { via: "prototype", key }; }
};
const inheritedHook = Object.create(prototype);
inheritedHook.visible = "original";
assert.equal(JSON.stringify({ item: inheritedHook }),
'{"item":{"via":"prototype","key":"item"}}');
These hooks do not need to appear as fields in the JSON to affect it. Check custom model instances at integration boundaries rather than assuming that the values displayed by an object inspector match what stringify will emit. Built-in objects can also define serialization behavior, so include their documented output in contract fixtures when they are accepted inputs.
Avoid patching global prototypes as a test shortcut. The isolated prototype above supplies the relevant behavior without changing unrelated objects in the process. That keeps failures attributable to the fixture and avoids making later tests depend on cleanup of global serialization hooks.
Treat a hook's return value as another contract boundary. An object may replace itself with a string, null, or a differently shaped object, so checking the original instance's declared fields is insufficient. Assert the parsed output type before validating its fields, and include a fixture whose hook deliberately changes that type if your response adapter accepts application instances.
6. Does explicit projection avoid accessor reads under a clearly limited input contract?
Test a small projection that accepts own data properties from a controlled ordinary object and rejects accessor descriptors before reading them. Object spread and direct property reads can execute getters while copying. Descriptor-based inspection can avoid that getter invocation for the shown input class, but it is not a sandbox for proxies or arbitrary untrusted JavaScript objects.
The property descriptor API exposes whether a property stores a value or accessor functions. Define the accepted input class and output fields before using this technique; do not turn it into an unsupported general safety claim.
function projectId(ordinaryRecord) {
const descriptor = Object.getOwnPropertyDescriptor(ordinaryRecord, "id");
if (!descriptor || !Object.hasOwn(descriptor, "value") ||
typeof descriptor.value !== "string") {
throw new TypeError("Expected own string data property id");
}
return { id: descriptor.value };
}
let idReads = 0;
const accessorRecord = {
get id() { idReads += 1; return "demo-2"; }
};
assert.throws(() => projectId(accessorRecord), TypeError);
assert.equal(idReads, 0);
assert.deepEqual(projectId({ id: "demo-2", internal: "omitted" }), { id: "demo-2" });
const spreadCopy = { ...accessorRecord };
assert.equal(idReads, 1);
assert.equal(spreadCopy.id, "demo-2");
console.log("All six getter test groups passed");
The helper validates only one field and does not recursively inspect nested values. Its narrowness is intentional: the emitted record has a reviewed data-only shape. Proxies can intercept descriptor operations, so crossing a hostile-object boundary requires a different design rather than a promise that this helper never executes any user code.
Use the JSON Viewer to inspect a sanitized completed transfer object after these assertions pass. The viewer helps confirm emitted fields and nesting; it cannot prove that getters were never executed or that projection enforced an application schema. Keep those execution and contract assertions beside the response-building code.
Rerun all six groups when changing domain models, serializers, logging, or response adapters. Record which runtime was tested and add browser or framework integration coverage where those environments matter. The useful outcome is evidence about when code executes and which representation reaches the send boundary, rather than an assumption that serialization only reads inert stored data.
When preserving a failing example, store the object-construction code rather than only its JSON output. Parsing a saved JSON string creates ordinary data and cannot recreate the accessor that caused the original failure. A reproducible getter test needs its descriptors, insertion order, counters, and expected exception, alongside the expected serialized representation. Synthetic construction code makes those assumptions reviewable without including production data.