Quick answer: Test four boundaries before sending Python-generated JSON: reject or deliberately handle non-finite numbers, prevent dictionary-key loss, constrain custom conversions, and inspect partial output after encoding failures. A successful json.dumps call does not prove that the receiver gets the intended fields, types, or one complete document.
Your API handler returns the expected object in a unit test, yet the wire representation can still lose a key or contain a token another parser rejects. The Python JSON module guide introduces the API; these tests focus on producer policies rather than the separate Python decoding policy checks.
Run the Python blocks together, in order. They were tested locally with Python 3.12.14 and its standard library, using synthetic data. No framework response class, remote endpoint, or storage system was tested. Wikipedia's serialization overview supplies background on converting program values into transferable representations; the Python documentation defines the encoder behavior below.
1. Does the encoder reject every non-finite float the contract disallows?
Test NaN, positive infinity, and negative infinity at nested value positions with allow_nan disabled. Python's default encoder permits those values and emits tokens outside standard JSON number syntax. A local Python round trip can therefore pass while hiding an interoperability failure, so include an explicit rejection policy rather than relying on successful decoding alone.
RFC 8259's number grammar excludes non-finite number tokens. The Python encoder documentation describes allow_nan as the switch controlling their emission. This is an encoder setting, not a guarantee that the rest of the payload matches your business schema.
import json
from decimal import Decimal
from io import StringIO
for value in [float("nan"), float("inf"), float("-inf")]:
loose = json.dumps({"metrics": [value]})
assert any(token in loose for token in ["NaN", "Infinity"])
try:
json.dumps({"metrics": [value]}, allow_nan=False)
except ValueError:
pass
else:
raise AssertionError("Non-finite value escaped the rejection policy")
assert json.loads(json.dumps({"metrics": [0.0, 12.5]}, allow_nan=False)) == {
"metrics": [0.0, 12.5]
}
The nested list matters. Checking only a top-level measurement before encoding leaves other fields untested when a response grows. Keep the encoder's rejection setting on the path that actually builds the response, and retain a nested invalid fixture when refactoring helper functions or response adapters.
Decide what a missing measurement means before replacing a non-finite value. Null, an omitted property, a documented error object, and a rejected response are different contracts. Automatically converting every invalid float to zero can turn an unavailable measurement into a plausible reading. The test should preserve the chosen meaning, not merely make a parser stop complaining.
A finite control also earns its place. A helper that always raises would pass a rejection-only suite. Pair one accepted payload with each rejected category, and assert that the accepted result preserves the intended numeric values. Keep cross-language numeric precision separate: rejecting infinity does not ensure that a large finite integer survives a JavaScript receiver unchanged.
Finally, test the error response produced when encoding fails. A framework may catch the exception, replace the body, or have already sent response headers. These fixtures establish the standard library's local behavior; integration coverage must establish which body and status the client receives when the producer refuses a value.
Do not use Python's permissive decoder as the only interoperability oracle for the default output. It accepts the same non-finite tokens the encoder can emit, so a successful dumps/loads round trip does not independently establish standard JSON syntax. Test the configured rejection directly, and include the receiver's parser in integration coverage when its behavior is part of the interface.
2. Do dictionary keys survive without skipping or name collisions?
Test an unsupported key and a pair of distinct keys that become the same JSON member name. skipkeys can discard unsupported entries, while supported non-string keys can still be coerced into names. For string-keyed API objects, reject non-string keys before serialization and inspect the raw output whenever duplicate names could otherwise be hidden by parsing.
The Python conversion rules warn that dictionary round trips can change keys. Setting skipkeys to false does not turn the encoder into a string-key-only validator. It rejects unsupported key categories, not every key requiring conversion.
unsupported = {("warehouse", 1): "hidden", "status": "ready"}
try:
json.dumps(unsupported, skipkeys=False)
except TypeError:
pass
else:
raise AssertionError("Unsupported key was accepted")
assert json.loads(json.dumps(unsupported, skipkeys=True)) == {"status": "ready"}
collision = {1: "numeric", "1": "text"}
raw = json.dumps(collision)
assert json.loads(raw, object_pairs_hook=list) == [
("1", "numeric"), ("1", "text")
]
assert json.loads(raw) == {"1": "text"}
if any(not isinstance(key, str) for key in collision):
rejected_by_application = True
else:
rejected_by_application = False
assert rejected_by_application
The collision exists in the encoded text before the receiving parser chooses how to handle repeated names. Looking only at the ordinary decoded dictionary discards that evidence. Use a pair-preserving decoder for the fixture, and use the duplicate-key guide when designing the receiving side's independent policy.
In production, apply key checks to every mapping location covered by the public contract. The compact application check above covers only its one dictionary and is intentionally not presented as a recursive validator. A response containing dictionaries inside lists needs coverage there too. Do not copy a top-level guard and claim it protects an entire object graph.
Skipping can be appropriate for a deliberately lossy export, but it should be named and measured as such. Compare source and accepted field sets in a synthetic test, and decide how the caller learns that data was omitted. Quietly dropping an unsupported identifier from an API result is a different product decision from rejecting a malformed internal record.
Sorting keys does not resolve a collision or restore omitted members. Formatting changes presentation, while key policy determines identity. Keep those concerns separate when reviewing serialization helpers. A tidy output file can still represent fewer fields than the source dictionary, and a normal parser can make duplicate emitted names look deceptively harmless.
Include a source-side distinction check when building collision fixtures. Python itself can consider some different-looking keys equal before serialization, such as a boolean and its corresponding integer value. Such a dictionary may already have overwritten an entry before the encoder runs. The integer 1 and string "1" used here remain distinct source keys, so the test isolates name conversion during encoding rather than dictionary construction.
3. Do custom conversions preserve a documented value shape and reject surprises?
Test the exact JSON type and representation returned for each supported custom object, then include an unsupported object that must fail. A default callback handles values without built-in encoding support; it does not transform every value. Broad fallback stringification can conceal new types, so keep conversion behavior explicit and independently test its failure path.
The JSONEncoder extension API requires a serializable replacement or an appropriate failure. This fixture chooses a tagged decimal representation as an example contract. It does not claim that this is the correct representation for every money or measurement API.
def encode_extra(value):
if isinstance(value, Decimal):
if not value.is_finite():
raise ValueError("Non-finite decimal")
return {"decimal": str(value)}
raise TypeError("Unsupported response value")
encoded = json.dumps({"price": Decimal("19.90")}, default=encode_extra,
allow_nan=False)
assert json.loads(encoded) == {"price": {"decimal": "19.90"}}
for value, error in [(object(), TypeError), (Decimal("NaN"), ValueError)]:
try:
json.dumps({"value": value}, default=encode_extra, allow_nan=False)
except error:
pass
else:
raise AssertionError("Custom conversion did not enforce its contract")
calls = []
def recording_default(value):
calls.append(type(value).__name__)
return str(value)
assert json.dumps(float("nan"), default=recording_default) == "NaN"
assert calls == []
The final assertion explains why a default callback cannot repair the float policy from the first test. A normal float already has built-in handling. The callback never sees it in this example, even though the resulting token is unsuitable for a standard JSON number. Test the controls separately instead of treating the hook as a universal serializer middleware.
For Decimal, the tagged string preserves the selected textual scale, including the trailing zero in 19.90. That is useful only if the receiver expects an object with a decimal member. A receiver expecting a JSON number would see a contract mismatch. Write the output shape into the API schema and validate it at the receiving boundary, rather than assuming successful conversion implies agreement.
Avoid using default=str as a substitute for deciding which custom types belong in a public response. An accidentally included internal object could become a string that leaks implementation detail or changes between releases. Explicit rejection makes that new type visible during development. The dataclass JSON tests cover object projection and field-selection issues before the encoder is called.
After producing a completed, sanitized, duplicate-free result, use the JSON Viewer to inspect its nested value shape. Expand price and confirm that it is an object containing a string in this contract. The viewer does not prove decimal arithmetic, schema compliance, or cross-language conversion correctness; those require separate assertions.
4. Can a serialization failure leave a prefix in the destination?
Test a valid early value followed by an unsupported late value, then inspect the destination after failure. dump and iterencode can expose chunks before encoding completes. If the interface requires a complete document before publication, serialize successfully into a bounded staging representation first, and treat later destination-write failures as a separate problem with separate guarantees.
The iterencode documentation describes yielding fragments as they become available. Chunk boundaries are not record boundaries. The fixture below checks a failed text sink and a completed chunk sequence without asserting a particular number or exact size of chunks.
sink = StringIO()
try:
json.dump([{"ready": True}, object()], sink)
except TypeError:
pass
else:
raise AssertionError("Late unsupported object was accepted")
assert sink.getvalue() != ""
try:
json.loads(sink.getvalue())
except json.JSONDecodeError:
pass
else:
raise AssertionError("Failed serialization unexpectedly left a full document")
payload = {"rows": [{"id": 1}, {"id": 2}]}
chunks = list(json.JSONEncoder(allow_nan=False).iterencode(payload))
assert json.loads("".join(chunks)) == payload
completed = json.dumps(payload, allow_nan=False)
unpublished = StringIO()
unpublished.write(completed)
assert json.loads(unpublished.getvalue()) == payload
print("All four encoding policy test groups passed")
Building a complete string first moves serialization failure ahead of the illustrated write. It does not make a file replacement atomic, guarantee network delivery, or prevent the destination from failing halfway through its own operation. The file-write checks cover publication concerns beyond this encoder boundary.
Staging has a cost: the complete representation occupies memory. Choose a size limit and failure behavior suitable for the service instead of buffering an unbounded dataset merely to simplify error handling. When streaming is required, document what consumers should do with interrupted records and how retries interact with already received data. Those are protocol decisions, not properties supplied by iterencode.
Also distinguish one document from a sequence of documents. Repeated dump calls do not add separators or a framing protocol automatically. A stream of event records needs a documented format, such as the JSON Lines approach, and tests for its actual delimiters and interruption behavior. Encoder fragments alone cannot define where one message ends.
Keep these four groups beside the helper that owns the outgoing representation. Rerun them when changing standard-library versions, encoder options, custom hooks, or response adapters. Then add representative integration fixtures against the actual receiver. Local assertions give precise evidence about encoding policy; end-to-end checks establish whether that policy survives the rest of the delivery path.
For failure diagnostics, record which policy rejected the representation and a safe field location when available. Avoid recording the complete rejected object merely because serialization failed: custom objects and customer fields can contain sensitive information. A synthetic regression identifier and exception category are usually more useful for reproducing the encoder behavior than an uncontrolled dump of application state.