Quick answer: Before releasing a closed JSON object contract, test local unknown-field rejection, composed and referenced schemas, successful branches, nested objects, pattern-based names, and the production validation pipeline. These six checks distinguish a schema that merely lists properties from one that reliably accepts the intended fields and rejects unintended ones.
An API accepts customerId, so a client sends customerID. The JSON parses, the request reaches the handler, and a default path runs because the expected key is missing. Declaring properties alone would not catch that integration mistake. An explicit unknown-field policy and a small fixture suite make the decision reviewable.
This guide uses Draft 2020-12 examples. Start with the JSON Schema introduction if schemas are new to your team. For a broad description of the data format itself, Wikipedia's JSON overview provides background; the validation rules below come from JSON Schema and validator documentation.
1. Does the local object reject an unknown property?
Begin with one known field, one misspelled field, and an explicit object type. Listing properties only describes those names; it does not close the object. Choose a local additionalProperties rule deliberately, then prove that a valid instance passes and the same instance with an unexpected field fails without being silently rewritten.
The official object reference explains the default openness of objects. Treat closure as a contract choice: rejecting misspellings can improve feedback, while rejecting every new response field may make clients less tolerant of additive evolution. Decide separately for request bodies, responses, configuration, and extension maps.
Use the following Python setup for all six examples. Install jsonschema==4.26.0 in an isolated environment, save the snippets together in order, and run the resulting file. The library validation documentation describes the explicit validator class and schema checking used here.
from copy import deepcopy
from jsonschema import Draft202012Validator
DIALECT = "https://json-schema.org/draft/2020-12/schema"
def accepts(schema, instance):
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema).is_valid(instance)
local = {
"$schema": DIALECT,
"type": "object",
"properties": {"customerId": {"type": "integer"}},
"required": ["customerId"],
"additionalProperties": False,
}
assert accepts(local, {"customerId": 42})
assert not accepts(local, {"customerId": 42, "customerID": 42})
assert not accepts(local, [])
Keep the negative payload almost identical to the positive payload. If it also changes a required value or uses an invalid type, a failure would not prove that closure works. Record which field was unexpected and which schema boundary should reject it.
Notice that required and closure answer different questions. Removing customerId should fail because the field is mandatory; adding customerID should fail because it is unknown. Give those cases distinct names in the suite so future reviewers can identify the rule that changed.
For request bodies, decide whether a typo should produce a field-specific error or be tolerated for compatibility. Write that decision into the endpoint contract before implementing the rejection, so client teams know what they must change.
2. Do references and allOf allow the intended combined fields?
Build a positive instance containing fields from each composed branch, then add one genuinely unknown property. A closed base schema can reject an extension field before an outer policy helps. Keep reusable pieces appropriately open, close the intended combined boundary, and verify the resolved schema rather than assuming composition merges declarations.
The official combination guide describes allOf as applying every subschema, rather than performing object inheritance. A practical test should therefore include the base, the extended contract, and a failing extension. Otherwise, a schema can appear strict simply because it rejects every useful instance.
composed = {
"$schema": DIALECT,
"type": "object",
"$defs": {
"base": {
"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"],
}
},
"allOf": [
{"$ref": "#/$defs/base"},
{"properties": {"label": {"type": "string"}}},
],
"unevaluatedProperties": False,
}
assert accepts(composed, {"id": 1, "label": "Desk"})
assert not accepts(composed, {"id": 1, "label": "Desk", "debug": True})
closed_base = deepcopy(composed)
closed_base["$defs"]["base"]["additionalProperties"] = False
assert not accepts(closed_base, {"id": 1, "label": "Desk"})
The final assertion is intentionally a failure demonstration, not a recommended production change. It protects the lesson against a future refactor that closes the shared base and accidentally removes extensibility.
Use the JSON Viewer to inspect field spelling and nesting before running these fixtures. It helps review the documents but does not evaluate schema references or closure. For external resources, also run the checks from the schema reference release guide against the actual resolver and registered resources.
When a reference moves between files, preserve the positive extension fixture. A successful download or compilation alone cannot prove that the receiving boundary still recognizes the intended fields. Store the schema artifact identifier beside each recorded verdict.
Test the reusable base directly as well as through the final endpoint schema. The two entry points can intentionally have different openness policies; naming both prevents a maintenance change from erasing that distinction without a failing test.
3. Do only successful branches contribute accepted properties?
Exercise each branch independently, both branches together where allowed, and a payload whose second branch contains the wrong value type. Seeing a property name somewhere in a schema does not make it universally accepted. Branch success and the surrounding composition determine which evaluated-property information can support an outer unevaluatedProperties restriction.
Ajv's keyword reference documents branch-sensitive behavior. The following fixture uses anyOf, so either supported capability can satisfy the combination. Both capabilities may also appear together when both corresponding branches pass. That is a different contract from requiring exactly one capability.
branches = {
"$schema": DIALECT,
"type": "object",
"anyOf": [
{"properties": {"a": {"type": "integer"}}, "required": ["a"]},
{"properties": {"b": {"type": "string"}}, "required": ["b"]},
],
"unevaluatedProperties": False,
}
assert accepts(branches, {"a": 1})
assert accepts(branches, {"b": "enabled"})
assert accepts(branches, {"a": 1, "b": "enabled"})
assert not accepts(branches, {"a": 1, "b": 2})
assert not accepts(branches, {})
The wrong-type case matters because branch a still succeeds. Without the outer restriction, a failing b branch would not necessarily prevent the overall anyOf from passing. The fixture checks that b cannot escape scrutiny merely because another branch was valid.
Name this case by its policy, such as valid-a-with-invalid-b-is-rejected, rather than by a temporary validator error message. Different implementations can report different error trees while agreeing on the final verdict. Assert the contract first; inspect detailed diagnostics when investigating a mismatch.
If the production design uses if and then, add equivalent fixtures for each selected condition. The conditional validation tests cover condition selection and missing discriminators. Keep that decision separate from whether unknown fields are allowed after the selected branch applies.
Keep positive fixtures for every supported branch alongside the negative cases. A release that rejects all requests is technically strict but unusable, and an isolated rejection assertion cannot distinguish that regression from a correctly enforced boundary.
4. Are nested objects closed at their own boundaries?
Place an unknown property inside a declared child object while keeping the root valid. Closing the root only controls root properties, so nested objects need their own explicit policies. Test each sensitive depth independently and preserve deliberately open maps, because recursive blanket rejection can break legitimate metadata or user-defined configuration keys.
The Draft 2020-12 core specification defines unevaluated-property behavior at the instance location being validated. Use that scope to design fixtures: a property under customer belongs to a different object from a property beside customer.
nested = {
"$schema": DIALECT,
"type": "object",
"properties": {
"customer": {
"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"],
"additionalProperties": False,
},
"metadata": {"type": "object", "additionalProperties": {"type": "string"}},
},
"required": ["customer"],
"additionalProperties": False,
}
assert accepts(nested, {"customer": {"id": 1}, "metadata": {"campaign": "autumn"}})
assert not accepts(nested, {"customer": {"id": 1, "admin": True}})
assert not accepts(nested, {"customer": {"id": 1}, "metadata": {"campaign": 2}})
Here, metadata accepts arbitrary names but restricts their values. That distinction is useful for trace labels, integration tags, and limited extension points. An open map does not have to mean unrestricted data, and a closed root does not have to force every child into the same policy.
Build a small review table for real endpoints: instance path, owner, allowed-name policy, allowed-value policy, and expected response on failure. Include array elements when they contain objects. A fixture for the first child is only enough if every element is governed by the same item schema.
Also test the handler boundary after validation. Rejecting an unexpected nested field is useful only if application code receives the validated payload and does not subsequently combine it with unreviewed request data.
When adding a new nested object, copy the fixture structure rather than assuming an existing parent test covers it. Review the new child policy with its owning team, especially when the object carries user-supplied settings or external metadata.
5. Can pattern rules or catch-all schemas accidentally admit extra fields?
Test the edges of every allowed-name pattern and inspect explicit catch-all rules in reused schemas. A permissive additionalProperties schema can evaluate fields that an outer unevaluatedProperties rule would otherwise reject. Distinguish intentional extension names from accidental matches, and test value constraints as well as names so permissive coverage cannot hide invalid data.
The JSON Schema object tour illustrates extending closed contracts. For production review, go further and test what an inherited catch-all actually admits. A schema that looks strict at its outer boundary can still deliberately evaluate all remaining properties inside a branch.
patterns = {
"$schema": DIALECT,
"type": "object",
"patternProperties": {"^x-[a-z]+$": {"type": "string"}},
"unevaluatedProperties": False,
}
assert accepts(patterns, {"x-team": "platform"})
assert not accepts(patterns, {"prefix-x-team": "platform"})
assert not accepts(patterns, {"x-team": 7})
catch_all = {
"$schema": DIALECT,
"type": "object",
"allOf": [{"additionalProperties": True}],
"unevaluatedProperties": False,
}
assert accepts(catch_all, {"unexpected": "still allowed"})
The final positive assertion should trigger a design conversation, not an automatic rewrite. Some contracts intentionally allow extensions. If the intended policy is rejection, remove or narrow the permissive branch and add an explicit negative fixture that fails before the fix and passes afterward.
Review exact matching boundaries with examples that differ by one character. Include uppercase letters, digits, empty suffixes, and a prefix before the expected extension marker if those cases matter to your API. Keep the pattern small enough that a reviewer can connect each boundary case to the corresponding rule.
Avoid treating unknown-field rejection as authorization. An allowed property name and a valid string do not prove that the caller may change that field. Permissions, business constraints, and storage rules still need their own checks after structural validation.
Keep extension names stable in documentation and examples. If clients already use an extension namespace, narrowing its pattern is a compatibility change; evaluate recorded payload samples before deciding whether immediate rejection or a migration period is appropriate.
6. Does the production pipeline preserve rejection without silent mutation?
Run the same fixtures through the real request path, record the dialect and validator configuration, and compare the payload before and after validation. Schema acceptance alone does not prove endpoint behavior. Preprocessing, field removal, generated schemas, or older consumers can change the contract before the handler sees the data or returns its response.
The 2019-09 release notes identify unevaluatedProperties as a newly introduced keyword for that dialect. Do not assume an older consumer enforces it. Use a deliberately failing smoke fixture whenever changing a gateway, validator, generated client, or API description format.
payload = {"customerId": 42, "extra": "reject me"}
before = deepcopy(payload)
assert not accepts(local, payload)
assert payload == before
fixtures = [
("known-field", {"customerId": 42}, True),
("unknown-field", {"customerId": 42, "extra": "x"}, False),
("missing-required", {}, False),
]
for name, instance, expected in fixtures:
assert accepts(local, instance) is expected, name
Ajv's data modification guide documents implementation options that remove properties or otherwise change data. Keep these options explicit in your deployment record. A request that passes after stripping extra has different observable behavior from a request rejected with a useful field error.
For API description conversions, inspect the generated artifact rather than only the source schema. The OpenAPI extension registry documents an extension representation for versions without direct support; its presence does not itself prove that every consumer enforces the constraint.
Finish with an endpoint test that sends the unknown field, checks the documented failure status, and confirms that no write or job was created. Add a valid request immediately afterward to prove the route still works. Preserve the fixture names and expected verdicts in continuous integration, and rerun them whenever schema composition or validation settings change.
Store the fixtures with the service that owns the contract, not only in a shared playground. Reviewers should see the changed schema, the changed expectations, and the deployment settings together when approving a release.