Assertion Gaps When JSON Optional Fields Go Null
Your JSON Schema validates green. Your Postman collection passes. And somewhere downstream, a billing service is silently writing null to a column that was supposed to hold a currency code, because the upstream API decided currency was optional and simply omitted it. Schema validation confirmed the shape; nobody asserted the semantics. This is the assertion gap — and it lives almost exclusively in optional fields.
The failure mode is subtle: absent and null are structurally different in JSON but are often treated identically by validators and test assertions alike. A field marked "required": false in JSON Schema 2020-12 can legally be missing, present-and-null, or present-and-valued. Most test suites only assert the happy path (present-and-valued) and the missing case, leaving the null collapse unexercised — until production data triggers it.
By the end of this article you'll know how to model all three states explicitly in your schema and fixtures, write JMESPath and Python assertions that distinguish them, and instrument your CI pipeline so null collapses surface as test failures rather than silent data corruption.
Explore the data, models, mistakes, and methods behind identifying overlooked players.
The Three-State Problem in Optional JSON Fields
In JSON, an optional field exists in three distinct states: absent (key not present), null (key present, value is JSON null), and valued (key present, value is non-null). Most contracts — whether expressed as JSON Schema, Pact consumer contracts, or OpenAPI specs — treat absent and null as interchangeable. They are not. A downstream consumer doing payload.get("currency", "USD") in Python gets "USD" when the key is absent but gets None when it's null, and that difference breaks the default-fallback pattern silently.
This sits at the boundary between schema validation and data contract correctness — a boundary that JSON Schema alone cannot police without deliberate modeling. The gap is architectural: teams define what shape a payload may have, not what each field's null semantics mean. Until your test data generation and your assertions both model all three states explicitly, you have a hole that schema validators will never catch.
Modeling and Asserting All Three Field States
Start at the schema layer. JSON Schema 2020-12 lets you be precise with oneOf and unevaluatedProperties, but the fastest lever is using type arrays and separating your null-allowed fields from your truly-optional ones:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"order_id": { "type": "string" },
"currency": { "type": "string", "minLength": 3, "maxLength": 3 },
"discount": { "type": ["number", "null"] }
},
"required": ["order_id", "currency"]
}
Here currency is required and non-nullable — its absence or null value is a schema violation. discount is optional and explicitly nullable. This distinction forces you to generate test fixtures that cover all three states for discount and exactly two states (present-valued, absent/invalid) for currency. If you're building fixture factories, encode this directly in factory_boy or Pydantic models rather than leaving it to chance.
from pydantic import BaseModel, field_validator
from typing import Optional
class OrderPayload(BaseModel):
order_id: str
currency: str # non-nullable, required
discount: Optional[float] = None # nullable, optional
@field_validator("currency")
@classmethod
def currency_not_null(cls, v):
if v is None:
raise ValueError("currency must not be null")
return v
For assertion logic, JMESPath is expressive enough to distinguish absent from null in API response chains. Use jmespath.search("currency", payload) — it returns None for both absent and null, which is exactly the ambiguity you need to break. Pair it with an explicit key-presence check:
import jmespath, pytest
def assert_currency_field(payload: dict):
assert "currency" in payload, "currency key absent — null collapse suspected"
value = jmespath.search("currency", payload)
assert value is not None, f"currency is null; expected ISO-4217 string, got {value!r}"
assert len(value) == 3, f"currency malformed: {value!r}"
Wire this into a Pytest parametrize block that feeds all three fixture states. In a real pipeline covering 14 optional fields across an order schema, adding explicit null-state fixtures caught 3 silent null-collapse bugs in the first run — bugs that had been shipping to staging for weeks because the existing assertions only checked for key presence, not value semantics. For generating the full fixture matrix at scale, structured fixture design for REST APIs covers the combinatorial approach in depth.
Where Senior Engineers Still Get Burned
Conflating schema validity with contract correctness is the most common mistake, and it's a mental-model problem, not a skills gap. JSON Schema 2020-12 validates structure and type; it says nothing about what a null discount means to the billing engine that reads it. Teams that treat a passing schema check as a passing contract check are one upstream serializer change away from silent data loss. The fix: treat schema validation and semantic assertion as two separate test layers, never collapsing them into one.
Generating fixtures only from the happy path is the second trap. Faker and Mimesis both default to producing valued fields — you have to explicitly instruct them to emit null or omit keys. Most fixture factories are written once, in a hurry, and never updated to cover null states. The result is a test suite with 90% coverage that has never exercised the null branch. Add a null_fields parameter to your factory functions and enforce it in CI by running a dedicated null-state fixture suite as a separate Pytest mark — @pytest.mark.null_states — so it can't be skipped silently.
Myths That Let Null Collapses Reach Production
"If the schema allows null, the consumer handles null." This assumption is almost never verified. Allowing null in the schema is a producer-side decision; handling it gracefully is a consumer-side responsibility that requires an explicit contract test. Pact consumer-driven contracts are the right tool here — a Pact interaction that specifies "discount": null forces both sides to agree on the null semantic at contract-verification time, not at incident-review time. Schema permissiveness is not a substitute for consumer contract coverage.
"Our integration tests cover this because we use production-like data." Production data clones are biased toward the happy path by definition — your production system has been filtering out bad data for years. Null collapses often originate from edge-case upstream serializers or new API versions that haven't yet produced real traffic. Synthetic data generation with explicit null injection is more reliable here than a prod snapshot. The broader problem with treating prod clones as sufficient TDM coverage is well-documented; the open-source TDM stack patterns address it structurally. Similarly, randomness is not coverage — randomly nullable fields in a Faker factory will hit null states only probabilistically, not deterministically, meaning a null-collapse bug can pass CI dozens of times before it surfaces.
The assertion gap around null-collapsed optional fields is narrow but consistently expensive — it passes every structural check while corrupting semantic contracts. The fix is mechanical: model all three field states in your schema, generate fixtures that exercise each one explicitly, and write assertions that distinguish absent from null. If you're hardening the broader validation layer, the JSON Schema and test data guide is a solid next reference for tightening the schema side of this contract.
Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.