Skip to content
JSON Schema

6 JSON Schema oneOf Branch Tests Before API Release

·10 min read
6 JSON Schema oneOf Branch Tests Before API Release - JSON Schema guide from JSON Prettifier

Quick answer: Test zero, one, and multiple successful branches; missing selection tags; overlapping numeric types; shared outer constraints; intentional anyOf overlap; and new variants against old fixtures. oneOf checks how many alternatives validate. It does not choose a branch by name or accept the first branch that looks plausible.

A payload can pass every field check you inspect and still fail the enclosing oneOf. The JSON Schema validation checklist introduces that trap. These six tests turn it into repeatable evidence without replacing the broader JSON Schema guide.

Run the Python blocks together, in order, using Python 3.12.14 and jsonschema 4.26.0. The examples were executed locally with Draft 2020-12 and synthetic values. They do not test a remote API, generated client, or another validator. The branches are self-contained; the diagnostic helper is not a general reference-resolution or annotation-evaluation tool.

1. Does oneOf accept exactly one matching branch?

Include fixtures that match no branches, one branch, and multiple branches, then compare those counts with the complete oneOf result. A passing branch alone does not establish a passing combination. Record the successful branch indices during diagnosis so an overlap failure is distinguishable from a payload that satisfies none of the alternatives.

The official combination reference defines the exact-one rule. Start with deliberately simple numeric constraints so the expected match list is obvious. Complicated nested documents make it harder to tell whether a failure comes from the combination or from an unrelated field rule.

from jsonschema import Draft202012Validator

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

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

def matches(branches, value):
    return [index for index, branch in enumerate(branches) if valid(branch, value)]

ranges = [
    {"type": "number", "minimum": 0},
    {"type": "number", "maximum": 10},
]
for value, expected in [("5", []), (-1, [1]), (11, [0]), (5, [0, 1])]:
    assert matches(ranges, value) == expected
    assert valid({"oneOf": ranges}, value) == (len(expected) == 1)

The string fixture is a useful negative control because neither numeric branch accepts it. The middle numeric value exposes overlap; the values outside the shared interval prove that each branch can succeed independently. Keep all four cases when replacing the toy ranges with your real alternatives.

Wikipedia's exclusive-or background helps explain exclusivity, but do not extend a multi-input parity shortcut to JSON Schema. With three or more alternatives, oneOf still means exactly one successful branch, not any odd number of successes. The schema specification, not the operator's informal nickname, defines the contract.

three_numeric = [{"type": "number"} for _ in range(3)]
assert matches(three_numeric, 7) == [0, 1, 2]
assert not valid({"oneOf": three_numeric}, 7)

This extra assertion catches a diagnostic helper that accidentally implements odd parity. Three identical branches are legal schemas, but their combination cannot accept a number under oneOf. In a real review, similarly broad branches may arrive through reused definitions rather than obvious duplication. Inspect the resolved alternatives and retain a fixture with several successes when debugging that situation. Do not infer exclusivity from distinct reference names: two differently named definitions can accept the same instance.

Keep match lists in internal diagnostics when useful, while avoiding sensitive fixture data in logs. A branch index is also not a stable business identifier: reordering alternatives changes the index without necessarily changing validation. Give variants explicit domain names in application documentation rather than making production behavior depend on their positions.

2. Is the variant tag actually required in every tagged branch?

Test an absent tag even when every branch constrains that tag with const. A property constraint applies when its property is present; it does not itself demand presence. Add required to tagged branches and include unknown tag values so a missing or unsupported variant cannot accidentally match several permissive alternatives.

The object properties reference separates property validation from required-property checks. A common implementation mistake is to read a branch's const value as a routing instruction, overlooking what the branch actually accepts when the relevant field is missing.

def variant(name):
    return {
        "type": "object",
        "properties": {"kind": {"const": name}},
        "required": ["kind"],
    }

tagged = [variant("email"), variant("sms")]
optional_tags = [{"properties": branch["properties"]} for branch in tagged]
assert matches(optional_tags, {}) == [0, 1]
assert not valid({"oneOf": optional_tags}, {})
assert matches(tagged, {}) == []
assert matches(tagged, {"kind": "fax"}) == []
assert matches(tagged, {"kind": "email"}) == [0]
assert valid({"oneOf": tagged}, {"kind": "email"})

The repaired empty-object case still fails, but for a different reason: no branch matches rather than both branches matching. That distinction matters when presenting errors. A client needs to supply a supported tag, not remove arbitrary fields until the overlap disappears.

Use the JSON Viewer to inspect a sanitized variant payload, expand nested objects, and confirm where the kind field sits. The viewer helps inspect data; it does not execute these schema rules or establish that a particular branch is valid. Keep those assertions in the selected validator.

If application middleware inserts tags, test the boundary before and after that transformation deliberately. An apparently successful client example may rely on a default that the server never applies. The schema default-value tests cover that separate assumption. A tag required by the public contract should not disappear from regression coverage merely because one frontend currently supplies it.

3. Do numeric alternatives overlap because integers are also numbers?

Test an integer against both integer and number alternatives before assuming those types form disjoint categories. JSON Schema treats integers as numbers too, so an integer can satisfy both branches and fail oneOf. If the contract allows every number, use that type directly; define narrower categories only when the domain needs them.

The numeric type reference describes integer and number behavior. The important release question is whether the intended accepted set matches the schema, not whether each alternative has a different type label in a generated form.

numeric = [{"type": "integer"}, {"type": "number"}]
assert matches(numeric, 4) == [0, 1]
assert not valid({"oneOf": numeric}, 4)
assert valid({"oneOf": numeric}, 4.5)
assert valid({"type": "number"}, 4)
assert valid({"type": "number"}, 4.5)

disjoint_numeric = [
    {"type": "integer"},
    {"type": "number", "not": {"type": "integer"}},
]
assert matches(disjoint_numeric, 4) == [0]
assert matches(disjoint_numeric, 4.5) == [1]
assert not valid({"oneOf": disjoint_numeric}, "4")

The disjoint version demonstrates the logic; it is not a recommendation to complicate every numeric field. When both alternatives trigger identical application behavior, the simpler number schema is easier to explain and maintain. Retain multiple branches only when the distinctions carry a useful contract meaning.

Do not confuse a schema type test with numeric precision or storage policy. A number can satisfy its schema and still be rounded by a consumer or exceed a database column's range. The JSON number precision guide addresses that separate boundary.

Keep a string containing digits in the fixture set. It catches an adapter that coerces strings before validation and makes it look as though schema types perform that conversion. Record the validator configuration and any preprocessing in your integration tests. The local examples validate the values as supplied, with no coercion or custom type checker.

4. Are shared constraints still checked outside the alternatives?

Assert the complete schema result even when a diagnostic helper reports exactly one successful branch. Constraints surrounding oneOf apply to the same instance and can reject it independently. Put genuinely shared requirements at the appropriate outer level, then include a fixture that selects a valid branch while failing one shared rule.

The Draft 2020-12 core specification defines the combination within the larger schema evaluation. A list of successful alternatives is useful evidence about one keyword. It is not a replacement for evaluating every applicable constraint in the production document.

envelope = {
    "type": "object",
    "properties": {"requestId": {"type": "string", "minLength": 1}},
    "required": ["requestId"],
    "oneOf": tagged,
}
missing_shared = {"kind": "email"}
assert matches(tagged, missing_shared) == [0]
assert not valid(envelope, missing_shared)
assert not valid(envelope, {"kind": "email", "requestId": ""})
assert valid(envelope, {"kind": "email", "requestId": "req-42"})

This is a useful test for refactoring duplicated branch rules into shared definitions. Preserve the overall acceptance assertions while changing where the constraints live. A refactor that appears to simplify two alternatives can accidentally drop a shared requirement or apply it only to one variant.

The envelope intentionally does not prohibit extra properties. If your API requires a closed object, test that rule separately with the object closure guide. Adding a closure keyword during an unrelated branch fix can change accepted inputs and obscure what the patch was meant to address.

When a production schema uses references, evaluate it with its actual registry and base identifiers. Extracting an isolated branch can change reference resolution or omit surrounding context. The small helper here is deliberately limited to self-contained schemas. Preserve complete-schema fixtures as the authoritative acceptance tests, and use branch isolation only as a diagnostic technique whose assumptions you can explain.

5. Does anyOf express an intentional overlap more accurately?

Use anyOf when the contract permits one or more alternatives to succeed, and prove that decision with an overlapping fixture. Changing the keyword changes the accepted set; it is not merely a way to silence an error. Application code must still define what happens when data satisfies more than one supported capability.

The official anyOf reference defines the at-least-one condition. Consider a contact object that may offer email, phone, or both. If both channels are legitimate, requiring exactly one matching capability rejects an intended payload rather than protecting a variant boundary.

capabilities = [
    {"type": "object", "required": ["email"]},
    {"type": "object", "required": ["phone"]},
]
both_channels = {"email": "demo@example.test", "phone": "5550100"}
assert matches(capabilities, both_channels) == [0, 1]
assert not valid({"oneOf": capabilities}, both_channels)
assert valid({"anyOf": capabilities}, both_channels)
assert valid({"anyOf": capabilities}, {"email": "demo@example.test"})
assert not valid({"anyOf": capabilities}, {})

These branches check presence only. They do not validate email syntax, telephone numbering, ownership, or deliverability. Keep those omissions explicit when adapting the fixture; a useful combination example should not imply a complete contact-validation policy.

Avoid attaching a universal speed claim to the keyword change. Evaluation work depends on the schema, required annotations, implementation, configuration, and input. If performance is the reason for a redesign, benchmark representative accepted and rejected documents in the target environment. These functional assertions measure acceptance, not throughput.

Also check application routing independently. An anyOf result does not decide which channel to use first or whether both should receive a message. Those are product rules. Keeping validation separate from action selection prevents an innocent schema cleanup from unexpectedly changing notifications, billing, or another side effect.

6. Can adding a new branch invalidate previously accepted fixtures?

Run previously accepted instances against every new oneOf alternative before release. Adding a permissive branch can make an old instance match twice and therefore become invalid. Test the old acceptance corpus, a valid new variant, and unsupported tags so an apparently additive schema change does not silently break existing clients.

The python-jsonschema validator documentation describes the validator API used throughout these fixtures. Keep the same dialect and options for the old and proposed schema. Otherwise, a changed validation environment can become mixed up with the effect of adding a branch.

old_examples = [{"kind": "email"}, {"kind": "sms"}]
too_broad = tagged + [{"type": "object"}]
for example in old_examples:
    assert valid({"oneOf": tagged}, example)
    assert len(matches(too_broad, example)) == 2
    assert not valid({"oneOf": too_broad}, example)

expanded = tagged + [variant("push")]
for example in old_examples + [{"kind": "push"}]:
    assert len(matches(expanded, example)) == 1
    assert valid({"oneOf": expanded}, example)
assert not valid({"oneOf": expanded}, {"kind": "fax"})
print("All six oneOf branch test groups passed")

The broad branch is a negative control demonstrating the regression. Do not remove old fixtures merely because the new schema rejects them; first decide whether that rejection is an intentional contract change. If it is intentional, document migration behavior rather than calling the change automatically backward compatible.

Finally, run representative fixtures through the generated client and the real server validator. A UI selecting one visible alternative does not prove that the server sees exactly one successful branch. Preserve the six local groups as fast diagnostics, then add integration evidence for the systems you actually deploy. That makes exclusivity a tested property of the contract rather than an assumption attached to a keyword name.

Frequently Asked Questions

Why does oneOf reject a payload that matches two schemas?
oneOf requires exactly one successful subschema. Two successful branches are therefore a validation failure, even when each branch looks reasonable independently. Count branch matches for a small fixture, then decide whether the contract requires mutually exclusive alternatives or intentionally permits overlap through a different combination such as anyOf.
Does a const tag make the property required?
No. A const constraint inside properties checks a tag when that property exists; it does not require the property to appear. Add the tag to required when variant selection depends on it. Test a missing tag, a wrong tag, and a valid tag independently instead of checking only happy-path examples.
Can integer and number branches overlap?
Yes. A JSON integer also satisfies the number type, so an integer instance can match both branches in a oneOf containing those alternatives. Use number alone when all numbers are permitted, or express the intended disjoint categories explicitly. Test integers and fractional values before treating the branches as separate variants.
Does validating each branch replace validating the full schema?
No. Branch checks help explain a combination, but surrounding constraints still apply to the complete instance. Shared required fields, object types, and other outer rules can reject an instance with exactly one matching branch. Keep assertions for both the branch match list and the complete production schema's validation result.
Is changing oneOf to anyOf always a safe fix?
No. anyOf accepts an instance when one or more branches pass, while oneOf requires exactly one. Changing the keyword can broaden accepted inputs and hide an unintended ambiguity. Make that change only when overlapping alternatives match the application contract, and test how application code handles data satisfying multiple branches.

Can you inspect the variant payload?

Explore a sanitized payload in JSON Viewer before running its schema assertions in your validator.

Open JSON Viewer