Quick answer: Test the static Response.json method for exact output values, synchronous serialization failures, custom serialization effects, status restrictions, and header overrides. It constructs a response; the instance method response.json reads one. A helper that successfully returns a Response can still emit the wrong JSON shape or an inconsistent media type.
A shared API wrapper can hide a double-encoded object behind a perfectly valid JSON string. These five groups make that boundary visible. The Fetch response-body tests cover consumption; this article tests construction before a response leaves the handler.
Run the JavaScript blocks together, in order, as one .mjs file. They were executed with Node.js v24.22.1 using the standard Response API. No remote server, framework wrapper, browser matrix, or proxy was tested. Wikipedia's serialization overview provides background; the platform references beside the examples define the behavior being asserted.
1. Does the static method encode the intended JSON value exactly once?
Pass the original value to Response.json, then assert the response status, default content type, and decoded body. Include a string that already contains JSON to expose accidental double encoding. The static method returns a Response immediately; it does not parse the input text into an object or return a body-reading promise.
MDN's static Response.json reference describes response construction. Start with a small object whose expected fields and character content are obvious. A test that only checks whether the result is a Response cannot distinguish the intended object from a JSON string containing that object's text.
import assert from "node:assert/strict";
assert.equal(typeof Response.json, "function");
const made = Response.json({ id: "order-42", label: "Café" });
assert.equal(made instanceof Response, true);
assert.equal(made instanceof Promise, false);
assert.equal(made.status, 200);
assert.equal(made.headers.get("content-type"), "application/json");
assert.deepEqual(await made.json(), { id: "order-42", label: "Café" });
const encodedText = JSON.stringify({ id: "order-42" });
const doubleEncoded = Response.json(encodedText);
assert.equal(await doubleEncoded.json(), encodedText);
assert.equal(await Response.json("ready").text(), '"ready"');
The string case is not invalid JSON. It is the wrong shape if the endpoint promises an object. That distinction helps diagnose a client error that appears far from the handler: parsing succeeds, then reading a field fails because the parsed result is a string. Test the promised type rather than treating parse success as the complete contract.
Use the JSON Viewer to inspect a sanitized serialized body and expand its nested fields. A tree can make an unexpected string wrapper easy to notice. It cannot verify the response status, headers, or which server helper created the body; retain those assertions in code.
If a lower layer already returns serialized bytes, decide which layer owns serialization. Repeatedly parsing and serializing merely to fit a helper can change lexical details and add unnecessary work. A raw-text Response path needs its own content-type policy and validity checks. Keep that path explicit instead of accepting both objects and pre-serialized strings under one ambiguous parameter name.
2. Do unsupported inputs fail synchronously at the construction boundary?
Assert synchronous exceptions for inputs that cannot produce the required JSON bytes, including top-level undefined, unsupported primitives, cyclic objects, and unhandled BigInt values. Do not test these failures as if an instance body-read promise had rejected. Construction can fail before there is any Response available for a caller to inspect or send.
The Fetch Standard's JSON construction algorithm invokes JSON serialization while creating the response. The BigInt serialization reference documents that separate JavaScript failure. Use exception classes rather than a runtime-specific diagnostic sentence in portable fixtures.
for (const value of [undefined, Symbol("missing"), () => 1]) {
assert.throws(() => Response.json(value), TypeError);
}
const cyclic = {};
cyclic.self = cyclic;
assert.throws(() => Response.json(cyclic), TypeError);
assert.throws(() => Response.json({ id: 9007199254740993n }), TypeError);
assert.deepEqual(await Response.json({ id: "9007199254740993" }).json(), {
id: "9007199254740993",
});
The final assertion demonstrates one deliberate representation choice: a large identifier is sent as a string. It is not a recommendation to change every numeric field into text. Select the representation with consumers, then check that the response helper preserves it. The number precision guide explains why a successful numeric conversion is not automatically lossless.
In an actual handler test, exercise the error-mapping path around response construction. If a serializer throws after business work has already committed, returning a useful error does not undo that work. Review transaction and retry behavior independently instead of assuming a response failure means the request had no side effects.
Avoid a broad fallback that sends an empty object for every serialization failure. It can convert a server bug into a misleading successful response. Choose a documented error representation, ensure that representation itself is serializable, and keep the status consistent with the outcome. These fixtures establish language-level failure timing; they do not choose an application's logging, observability, or recovery policy.
A user-defined serialization hook can throw its own exception, so do not assume every construction failure is a TypeError. Add a fixture with a deliberate hook error and assert that the surrounding handler preserves the useful failure stage. That catches error mapping which incorrectly labels all serialization failures as malformed client JSON. Keep unexpected internal details out of public responses while retaining enough approved diagnostic context to distinguish a rejected input value from a broken server-side hook.
const hookError = new Error("fixture hook failure");
assert.throws(() => Response.json({ toJSON() { throw hookError; } }),
error => error === hookError);
3. Does the serialized body reflect hooks and omissions your contract permits?
Assert the body after custom toJSON hooks and ordinary JSON omissions have run, then verify that later source-object mutations do not change an already constructed response. The static helper serializes values; it does not preserve every JavaScript property or validate the resulting application shape. A source object and its encoded document can differ materially.
MDN's JSON.stringify documentation describes hooks and value handling. This fixture combines a nested hook with undefined in two contexts. The output assertions deliberately name the transformed result instead of comparing it to the original JavaScript object.
const input = {
user: { internalName: "Ada", toJSON() { return { displayName: this.internalName }; } },
optional: undefined,
slots: [undefined],
};
const snapshotResponse = Response.json(input);
input.user.internalName = "Grace";
input.slots[0] = "changed";
assert.deepEqual(await snapshotResponse.json(), {
user: { displayName: "Ada" },
slots: [null],
});
An omitted optional member and an array slot containing null mean different things. If the API requires an explicit nullable member, supply it intentionally and test it as such. If the API forbids null array entries, validate that condition before constructing the response. The helper's ability to serialize the input does not establish that either result is useful to the consumer.
Review model classes returned by data-access layers before handing them directly to a response helper. A toJSON method can be convenient, but it also moves public response-shape decisions into another abstraction. Projecting a plain response object makes field selection easier to audit and reduces the chance that an unrelated model change silently alters an endpoint.
Keep a realistic hook fixture beside the endpoint tests when hooks are part of the design. A handwritten plain object may bypass the actual serialization behavior of the production model. Conversely, do not claim this snapshot assertion tests streaming or incremental output; the static method has already encoded the input in this example.
For deeper traversal and filtering policies, use the JSON.stringify replacer tests. Response.json does not take a replacer parameter. If custom serialization is needed, design an explicit projection or a separately tested serialization path instead of assuming the static method exposes every JSON.stringify option.
4. Are status codes compatible with the body being constructed?
Test the intended success and error statuses, reject invalid constructor ranges, and exercise statuses that prohibit bodies. Passing null to Response.json still serializes the JSON text null, which is a body. For a documented no-content response, construct a response with a null body rather than asking the JSON helper to create empty JSON.
The Response constructor reference describes initialization and status restrictions. Treat those restrictions separately from HTTP application semantics. A constructible status does not establish that the chosen code correctly describes your endpoint's result.
const created = Response.json({ id: "order-42" }, { status: 201 });
assert.equal(created.status, 201);
assert.equal(created.ok, true);
const rejected = Response.json({ error: "Invalid quantity" }, { status: 422 });
assert.equal(rejected.ok, false);
assert.deepEqual(await rejected.json(), { error: "Invalid quantity" });
for (const status of [204, 205, 304]) {
assert.throws(() => Response.json(null, { status }), TypeError);
}
for (const status of [0, 199, 600]) {
assert.throws(() => Response.json({}, { status }), RangeError);
}
const noContent = new Response(null, { status: 204 });
assert.equal(noContent.body, null);
assert.equal(await noContent.text(), "");
Keep the null-body distinction visible in the handler's public contract. A JSON null result can be a valid successful representation for some endpoints, while a no-content response means there is no representation to parse. Interchanging them without updating the client can turn an otherwise successful request into a parsing error.
Test the real handler's status branches, not only these constructors. A helper with a default success status can accidentally label an error envelope as successful when callers forget an option. Likewise, a generic wrapper can attach a body to a no-content branch that previously worked correctly.
Include an error-status payload assertion because a non-successful status does not prevent the instance json method from reading a valid body. Client code should decide how to handle the status and application error together. The JSON API error response guide covers envelope design beyond the small illustrative error used here.
5. Do explicit headers remain consistent with the serialized bytes?
Assert headers and body together when response options come from a shared helper. An explicitly supplied content-type is retained instead of being replaced by the JSON default, so a stale header can label JSON bytes incorrectly. Define which layer owns the final media type and test that ownership after all option merges have occurred.
The Fetch Standard's response initialization rules define how a supplied content type interacts with the extracted body's type. A constructor-level fixture catches header merging mistakes that would be invisible in a test checking only the parsed body.
const mislabeled = Response.json({ ok: true }, {
headers: { "Content-Type": "text/plain", "X-Request-ID": "req-42" },
});
assert.equal(mislabeled.headers.get("content-type"), "text/plain");
assert.deepEqual(await mislabeled.json(), { ok: true });
function applicationJson(data, init = {}) {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
return Response.json(data, { ...init, headers });
}
const consistent = applicationJson({ ok: true }, {
status: 202,
headers: { "Content-Type": "text/plain", "X-Request-ID": "req-42" },
});
assert.equal(consistent.status, 202);
assert.equal(consistent.headers.get("content-type"), "application/json");
assert.equal(consistent.headers.get("x-request-id"), "req-42");
assert.deepEqual(await consistent.json(), { ok: true });
console.log("All five Response.json construction test groups passed");
The wrapper intentionally owns application/json. It is unsuitable unchanged for an endpoint whose documented representation uses a different JSON media type. Do not overwrite that type merely because the body is JSON-compatible. Define separate helpers or explicit policies where representations differ, and preserve the relevant schema and error contracts.
Also avoid interpreting successful instance parsing as header validation: the mislabeled fixture still parses because the method reads and parses the body. A client, gateway, cache, or security policy can make decisions from the header independently. The JSON content-type guide explains that metadata boundary.
Test both plain header objects and an existing Headers instance if callers use both. The wrapper creates a new Headers object so applying its content-type policy does not rewrite the caller's shared header collection. Assert that independence as well as the final output. Otherwise, constructing one response could alter options later reused by another response branch, making a test pass or fail depending on execution order rather than the endpoint's intended representation.
const sharedHeaders = new Headers({ "Content-Type": "text/plain" });
applicationJson({ ok: true }, { headers: sharedHeaders });
assert.equal(sharedHeaders.get("content-type"), "text/plain");
Finally, inspect the response emitted by the deployed adapter. Frameworks and intermediaries can change status, headers, compression, and body delivery after this object is created. Keep these five groups as fast construction regressions, then add endpoint integration tests for the actual response path. That separates evidence about the standard helper from evidence about the system serving your API.