Skip to content
Advanced

5 JSON Schema Dependency Tests Before API Release

·10 min read
5 JSON Schema Dependency Tests Before API Release - Advanced guide from JSON Prettifier

Quick answer: Test dependency triggers by presence, follow chained requirements, verify that dependentSchemas evaluates the whole current object, isolate nested scope, and check dialect migration with a known rejection. These five checks distinguish field relationships from value rules and expose schema fragments that compile while enforcing a different contract than intended.

An optional delivery option suddenly makes a contact field mandatory. That sounds simple until a null trigger, nested payload, or older validator changes the outcome. The conditional validation overview introduces the keywords; this guide adds a focused regression suite for dependency behavior.

Run the Python blocks together, in order. They were tested with Python 3.12.14 and jsonschema 4.26.0. They use Draft 2020-12 deliberately, not a claim that it is the newest dialect. The fixtures are synthetic and exercise local validators, not an endpoint or vendor integration. Wikipedia's JSON metadata and schema background explains the broader contract concept; the technical references below define these particular rules.

1. Does every present trigger activate its companion requirement?

Test the trigger as absent, null, false, zero, and an empty string before testing normal values. dependentRequired reacts to member presence, so each present value activates the requirement. Keep value validation separate: a property can trigger a dependency and still fail its own type or content constraint in the same instance.

The Draft 2020-12 validation specification defines the relationship in terms of names present in the object. A condition such as “if express is true” is different from “if express exists.” Choose the actual business meaning before choosing the keyword.

from jsonschema import Draft202012Validator, Draft7Validator

DIALECT = "https://json-schema.org/draft/2020-12/schema"

def valid(schema, instance):
    document = {"$schema": DIALECT, **schema}
    Draft202012Validator.check_schema(document)
    return Draft202012Validator(document).is_valid(instance)

presence = {
    "type": "object",
    "dependentRequired": {"express": ["contact"]},
}
assert valid(presence, {})
for value in [None, False, 0, "", "yes"]:
    assert not valid(presence, {"express": value})
    assert valid(presence, {"express": value, "contact": None})

with_values = {
    **presence,
    "properties": {"contact": {"type": "string", "minLength": 1}},
}
assert not valid(with_values, {"express": True, "contact": None})
assert not valid(with_values, {"express": True, "contact": ""})
assert valid(with_values, {"express": True, "contact": "desk-4"})

The null companion passes the presence-only schema intentionally. The dependency requires a name, not a usable contact channel. The later value rule rejects it. Keeping those assertions next to each other prevents a passing presence fixture from being presented as a complete delivery contract.

Include a trigger-absent object that contains only the companion. That tests whether the relationship is truly optional on one side. A form may collect a contact for ordinary orders too, so forbidding that object would be an unintended restriction rather than stronger validation.

When clients serialize JavaScript state, check the final JSON text rather than only the in-memory object. An undefined object member can disappear during serialization, whereas an explicit null remains. The null versus undefined guide covers that boundary. A dependency test should use the instance the server actually receives.

A practical fixture name should state the expected failure, such as express-present-contact-absent, rather than just invalid-order. That makes a later failure understandable when another constraint is added. Pair it with a complete object differing only in the companion field, so unrelated changes cannot accidentally become the reason the negative fixture fails.

2. Do chained and cyclic requirements preserve the intended accepted set?

Test every reachable companion in a dependency chain, including the final member, and test both sides of an intentional cycle. Requirements combine over the supplied object; they do not populate it or infer values. A cycle can allow neither property or require both once either appears, without making either property globally mandatory.

The official conditional reference explains that dependency directions must be declared. Extending that idea to a chain makes a common review mistake visible: accepting the first pair while forgetting that the second member itself activates another rule.

chain = {
    "type": "object",
    "dependentRequired": {"shipment": ["address"], "address": ["country"]},
}
assert valid(chain, {})
assert not valid(chain, {"shipment": True})
assert not valid(chain, {"shipment": True, "address": "dock-2"})
assert valid(chain, {"shipment": True, "address": "dock-2", "country": "IN"})
assert not valid(chain, {"address": "dock-2"})
assert valid(chain, {"country": "IN"})

paired = {
    "type": "object",
    "dependentRequired": {"start": ["end"], "end": ["start"]},
}
assert valid(paired, {})
assert not valid(paired, {"start": 1})
assert not valid(paired, {"end": 2})
assert valid(paired, {"start": 1, "end": 2})

The final paired object says nothing about whether start precedes end. Both names exist, which satisfies this structural relationship. Comparisons between arbitrary instance values need a separately supported mechanism or application logic; do not advertise a presence cycle as a range-order validator.

For larger contracts, sketch the field relationships before writing fixtures. One arrow means one direction. Then select representative objects that stop at each edge, contain all required companions, or contain only an untriggered leaf. This exposes accidental global requirements without needing every possible combination of optional fields.

Watch for requirements made redundant by root required lists. If address is always required, shipment requiring address adds no new restriction. That may still be useful documentation, but the suite should not pretend a redundant assertion proves the trigger works. Use an isolated fragment test plus the full endpoint schema to see both local semantics and final acceptance.

3. Does the triggered subschema evaluate the whole object without merging definitions?

Test a dependentSchemas branch with a sibling requirement and a sibling value constraint. The branch evaluates the whole current instance, not just the triggering member's value, and its restrictions apply alongside the surrounding schema. Property declarations in separate branches do not automatically merge into one shared list of allowed names for additionalProperties.

The Draft 2020-12 core specification defines the whole-instance behavior. This is useful for an optional mode that activates several restrictions together, but it also means object-closure rules require deliberate placement.

promotion = {
    "type": "object",
    "dependentSchemas": {
        "coupon": {
            "required": ["discount"],
            "properties": {"discount": {"type": "integer", "minimum": 1}},
        }
    },
}
assert valid(promotion, {})
assert not valid(promotion, {"coupon": "SPRING"})
assert not valid(promotion, {"coupon": "SPRING", "discount": 0})
assert valid(promotion, {"coupon": "SPRING", "discount": 5})
assert valid(promotion, {"discount": "not checked by inactive branch"})

branch_closed = {
    "type": "object",
    "properties": {"coupon": {"type": "string"}},
    "dependentSchemas": {
        "coupon": {
            "properties": {"discount": {"type": "integer"}},
            "additionalProperties": False,
        }
    },
}
assert not valid(branch_closed, {"coupon": "SPRING", "discount": 5})

The inactive-branch assertion is a useful negative control. If discount must always be an integer, put that rule in an unconditional applicable schema too. Restricting it only inside a coupon-triggered branch leaves the no-coupon case outside that rule, whether or not the field name looks closely related.

The closed branch rejects coupon because that branch does not declare it. A root properties entry does not change the local meaning of additionalProperties inside the dependent subschema. This rejection is expected under the shown schema, not a validator defect. The object closure tests cover ways to design composed allowed-property policies.

Keep error interpretation as precise as the schema. A missing discount, an invalid discount, and a disallowed coupon are different failures even though the same trigger activates them. Preserve safe instance paths and keyword names in diagnostics. Avoid logging real coupon tokens or customer contact values merely to explain a structural rejection.

For each activated branch, include at least one accepted instance before adding more rejection cases. A schema that rejects everything can make a rejection-only suite look reassuring. The branch-closure example is intentionally such a warning for any object containing coupon: its own local allowed-name policy rejects the trigger that activated it. Review that contradiction before adapting the example into production.

Test a nested trigger with the companion only at the root, then move the companion into the nested object and compare results. Dependencies apply at their current instance location. A matching name elsewhere in the document does not satisfy the rule, and dot-separated strings are literal property names rather than automatic paths through nested objects.

Use the JSON Viewer to expand a sanitized nested fixture and inspect the two contact locations before running the validator. The viewer helps identify structure and copy paths; it does not evaluate dependentRequired or dependentSchemas. The JSON Schema guide provides broader schema-location context.

nested = {
    "type": "object",
    "properties": {
        "delivery": {
            "type": "object",
            "dependentRequired": {"express": ["contact"]},
        }
    },
}
assert not valid(nested, {"delivery": {"express": True}, "contact": "root"})
assert valid(nested, {"delivery": {"express": True, "contact": "local"}})
assert valid(nested, {"delivery": {}})

literal_name = {
    "type": "object",
    "dependentRequired": {"express": ["delivery.contact"]},
}
assert not valid(literal_name, {"express": True, "delivery": {"contact": "nested"}})
assert valid(literal_name, {"express": True, "delivery.contact": "literal"})

The literal-name case catches an easy mistake when porting rules from a form library that uses dotted paths. JSON Schema property-name arrays do not inherit that library's addressing conventions. If the public JSON structure is nested, express that structure through properties and nested schemas rather than embedding a path-looking string in required names.

Also test a non-object at a location where object keywords appear. The dependency keywords alone do not require an object. The examples declare type explicitly because the contract needs object-shaped delivery data. Omitting that type can leave a string or array unaffected by a dependency that reviewers assumed would reject it.

Schema scope is also a maintenance issue. Moving a fragment beneath properties changes which instance it sees. Preserve fixtures with an identically named root and child member when refactoring shared schemas. Those fixtures expose a scope regression more clearly than a document where every repeated contact value happens to exist at both levels.

5. Does a dialect migration keep dependency rejections working?

Run a known-invalid dependency fixture before and after changing dialects or validator initialization. Draft 7 uses dependencies, while Draft 2020-12 separates property requirements into dependentRequired and schema dependencies into dependentSchemas. A schema that compiles with an unrecognized keyword can still miss the intended rejection, so verify instance behavior as well as schema syntax.

The python-jsonschema validation documentation describes explicit validator classes and dialect selection. The following test deliberately passes a modern keyword to a Draft 7 validator to expose a silent coverage gap in that particular local configuration. Other tools may reject unknown keywords instead.

modern = {"type": "object", "dependentRequired": {"express": ["contact"]}}
legacy = {"type": "object", "dependencies": {"express": ["contact"]}}
missing = {"express": True}
complete = {"express": True, "contact": "desk-4"}
assert not valid(modern, missing)
assert valid(modern, complete)
Draft7Validator.check_schema(modern)
assert Draft7Validator(modern).is_valid(missing)
Draft7Validator.check_schema(legacy)
assert not Draft7Validator(legacy).is_valid(missing)
assert Draft7Validator(legacy).is_valid(complete)
print("All five dependency test groups passed")

Do not solve this by stripping keywords until a schema compiles. That can remove the very rule that protects the endpoint. Determine the supported dialect of every consumer, including form editors, gateways, generated clients, and runtime validators. A schema accepted by a local editor does not establish that the deployed server enforces it.

For a migration, keep a small accepted-and-rejected corpus under version control. Translate both forms of the old dependencies keyword according to whether each value is an array of names or a subschema. Then compare outcomes deliberately, documenting any intended contract change instead of assuming all differences are compatibility bugs.

Finish with an integration check against the actual request pipeline. Decoding, preprocessing, defaults, and field removal can change member presence before validation. The local examples prove these five schema behaviors with the stated library; they do not prove that an endpoint invokes that library on an unchanged request. Record that boundary so the evidence remains useful and honest.

When capturing migration results, compare validity first and diagnostics second. Different validators can report different error orders or wording while agreeing on the accepted instance set. Assert the relevant keyword or a stable application error category when those details are part of your interface, rather than freezing a library's complete English sentence without a reason. Keep raw personal data out of the recorded corpus; synthetic fixtures make the regression portable and easier to share during code review.

Frequently Asked Questions

Does a null property activate dependentRequired?
Yes. The trigger depends on whether the member exists, not whether its value is truthy or useful. A null value can activate a companion requirement while also failing a separate value constraint. Test absence and explicit null independently, and use properties to define which trigger values your contract accepts.
Are JSON Schema property dependencies automatically mutual?
No. A dependency from one property to another does not create a reverse requirement. If two properties must appear together, declare both directions and test each property alone. Chained dependencies can require further companions, but they still validate the supplied object rather than adding any missing members to it.
Does dependentSchemas validate only the triggering value?
No. The triggered subschema evaluates the entire object at the current instance location. A required list inside that subschema refers to members of that object. To constrain the trigger's value, place its name under properties within an applicable schema, rather than assuming the subschema receives just that single value.
Can a nested dependency require a root property?
A dependency declared inside a nested object's schema checks that nested object. A similarly named member at the document root does not satisfy its required list. Put a relationship at the schema location whose instance contains the relevant members, or enforce cross-location business relationships in a suitable application-level validation step.
Can Draft 7 validators enforce dependentRequired?
Draft 7 defines the older dependencies keyword rather than dependentRequired and dependentSchemas. A validator may reject unfamiliar keywords or ignore them according to its configuration. Select the intended dialect explicitly and test a known-invalid instance; schema compilation alone does not prove that a modern dependency rule is being enforced.

Can you see the object where the dependency runs?

Expand a sanitized fixture in JSON Viewer, then run the applicable schema with your deployed validator.

Open JSON Viewer