Quick answer: Test JSON Schema tuples by checking each position, minimum length, extra elements, optional suffixes, null placeholders, composition, dialect migration, and deployment behavior. These eight checks target Draft 2020-12. A list of positional schemas does not, by itself, require a complete fixed-length record or reject an unexpected tail.
An endpoint expects ["sensor-a",12]. A short array passes validation, then the handler tries to read a missing measurement. The problem is a contract that described existing positions without requiring them.
This guide focuses on positional records rather than ordinary lists. Start with the JSON Schema overview for the wider validation model. Wikipedia’s tuple background explains ordered collections; JSON represents these examples as arrays, with the positional meaning supplied by the API contract.
1. Does each array position enforce its own value schema?
Use prefixItems to associate each existing array position with a specific schema. Test a valid record, swapped elements, and a wrong value at each index. Keep the top-level array type explicit so another JSON type cannot bypass array-specific assertions, and distinguish position-sensitive records from lists whose elements all follow one common schema.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "number"}]
}
The record ["sensor-a",12] passes, while [12,"sensor-a"] fails. Also test ["sensor-a","12"]: the measurement is a string, not a number. A client serializing every cell as text should not accidentally satisfy a numeric API contract merely because the displayed content looks similar.
The official tuple lesson introduces position-dependent validation. An important limit remains: two positions sharing the same type can still be exchanged without a type error. If both latitude and longitude are numbers, type assertions alone do not prove that callers placed them in the intended order.
Name positions in documentation and in fixture descriptions. Add domain ranges or stronger constraints where appropriate, but do not claim ranges can detect every swap. If the record becomes difficult to explain, consider a named object. Compact encoding is useful only when every producer and consumer agrees on what each index means.
2. Does the minimum length require every mandatory position?
Add minItems for positions that must exist, because prefixItems alone accepts arrays shorter than its schema list. Test the empty array, every incomplete prefix, and the complete record. Treat missing positions as a separate failure from wrong types at existing positions so a required measurement cannot disappear behind a successful prefix check.
{
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "number"}],
"minItems": 2
}
Without minItems, both [] and ["sensor-a"] pass the schema in check one. With the minimum above, both fail, while the complete record passes. This is not a parser issue: the shorter arrays are valid JSON. The schema needs an independent assertion that the second element exists.
The Draft 2020-12 validation specification defines array length assertions separately from positional evaluation. Include the lower boundary in generated fixtures rather than relying on one handwritten example. A schema generator can retain every positional type while accidentally dropping the minimum during a migration.
A minimum does not impose a maximum. The schema here still permits a third element. That deliberate intermediate state makes the next test meaningful: fixed-length tuples need both a required prefix and a policy for everything after it. Keep the minimum and tail fixtures separate so a future change to either policy is visible in review.
3. Are elements after the prefix rejected or validated deliberately?
Set items to false to reject every element after the prefix, or provide a tail schema when extra elements are allowed under a common rule. Test the first extra position and several later positions. Do not assume that defining prefixItems closes the array, and do not confuse validating the tail with validating every element.
{
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "number"}],
"minItems": 2,
"items": false
}
This schema accepts ["sensor-a",12] and rejects ["sensor-a",12,true]. If you replace false with {"type":"boolean"}, the boolean tail becomes valid, but a string in that tail fails. The numeric element at index one is still checked by its prefix schema, not by the boolean tail rule.
Use the core specification’s items definition to verify that boundary. Then decide whether an open tail is actually part of the product design. Unnamed extras can become accidental extension fields that old clients silently ignore while newer clients give them meaning.
Keep payload inspection honest. The JSON Viewer can help inspect the order and structure of a sanitized record; it does not execute the tuple contract. Preserve automated extra-element failures even when the array looks correct in a tree view. Readability and schema acceptance answer different questions.
4. Can optional trailing positions be omitted without shifting meaning?
Represent an optional suffix by setting minItems to the required prefix length while retaining positional schemas for later values. Test the shorter allowed form, the longer allowed form, and an unexpected extra element. Optionality works at the tail; removing an earlier value shifts later indexes and does not leave a meaningful empty slot behind.
{
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}],
"minItems": 2,
"items": false
}
Here the first two values are mandatory, and the third is an optional quality flag. Both ["sensor-a",12] and ["sensor-a",12,true] pass. ["sensor-a",true] fails because the boolean has shifted into the numeric measurement position. An optional flag does not make the measurement optional.
Document whether omission has an application-level interpretation, such as an unspecified quality state. Avoid inserting a default merely to make every record the same length unless that transformation is part of the contract. The default value tests explain how to verify such normalization separately from ordinary schema validation.
Versioning matters when adding a suffix. A previously closed two-element schema rejects the new three-element form even if the new producer calls it optional. Test the old consumer against new examples before describing an added suffix as backward compatible. Optional for the sender does not necessarily mean acceptable to every receiver.
5. Is an explicit null placeholder allowed only where the contract intends it?
A null element occupies an array position and must satisfy the schema for that index. Test a missing trailing value, an explicit null, and a value shifted into the nullable position. Allow null deliberately when it represents an unavailable field, and keep that policy separate from permitting shorter arrays or accepting malformed sparse-array syntax.
{
"type": "array",
"prefixItems": [{"type": "string"}, {"type": ["number", "null"]}, {"type": "boolean"}],
"minItems": 3,
"items": false
}
The record ["sensor-a",null,true] passes. ["sensor-a",true] fails both the intended positional contract and its minimum length. A notation such as ["sensor-a",,true] is not valid JSON and must fail parsing before the schema is evaluated. These cases must not collapse into one generic “missing data” example.
The null versus undefined guide covers the broader representation boundary. For tuple APIs, the practical rule is to preserve indexes deliberately. Null can keep the third field at index two, but it also expands the set of values downstream code must handle at index one.
Test the handler’s unavailable-value behavior as well as schema validity. A nullable measurement should not flow into arithmetic as if it were an ordinary number. Schema acceptance establishes the permitted representation; application logic still decides whether that record can be stored, displayed, or processed for the operation being requested.
6. Does tuple closure still work when the prefix lives in a composed schema?
Test closure against the complete composed schema rather than assuming items can see a prefix declared in a sibling branch. A local items restriction and evaluation-aware unevaluatedItems serve different roles. Use accepted and rejected fixtures around the composition boundary, especially when a shared definition supplies the positions and an outer wrapper closes the final record.
{
"type": "array",
"allOf": [{"prefixItems": [{"type": "string"}, {"type": "number"}]}],
"minItems": 2,
"unevaluatedItems": false
}
The complete two-element record passes, and a third element fails. Replacing unevaluatedItems with a sibling items:false changes the result: with no adjacent prefixItems, that items rule applies from the beginning and rejects the nonempty record. An assertion that appears stricter can therefore break every intended input.
The core specification’s unevaluatedItems section describes evaluation-aware closure. Keep the fixture tied to the whole generated schema, including references and branches. Passing a fragment in isolation cannot establish what the wrapper permits once every keyword participates.
For related evaluation behavior, review the contains tests. Keep those membership fixtures separate from positional fixtures: one asks whether some entries match, while the other assigns meaning by index. Combining them requires explicit tests of both policies rather than assuming that one successful array keyword establishes the entire contract.
7. Did migration update the tuple keywords as well as the dialect declaration?
Migrate positional schemas and tail rules together when moving from older dialects to Draft 2020-12. Test the old and new schema with their respective validators against the same fixture matrix. A changed schema identifier alone does not translate array-valued items into prefixItems, and leaving legacy tail keywords behind does not preserve their former validation effect.
In Draft 7, a positional example uses "items":[{"type":"string"},{"type":"number"}] and "additionalItems":false. The equivalent Draft 2020-12 form uses prefixItems with the same schema list and items set to false. Retain minItems in both forms when both positions are mandatory.
The official release notes explain this keyword redesign. Do not reuse a pre-2020 example without checking which dialect its validator selected. A schema loader should reject an invalid schema early, before request handling starts; silently ignoring unsupported keywords can produce a much weaker contract.
Also check the actual schema consumer. Some services accept only a subset of JSON Schema, and generated clients can impose additional restrictions. A local validator accepting a tuple schema does not prove that every remote endpoint will register it. Verify the supported dialect and keyword set for each integration instead of treating a historical compatibility report as a current vendor guarantee.
8. Does the production validator reproduce the full boundary fixture matrix?
Run schema checks and instance fixtures with the same dialect and configuration used by the API. Preserve the original array, expected validity, and failing boundary for every test. Include parser-level malformed JSON separately, then verify the real request adapter passes accepted arrays to the handler without reordering, padding, filtering, or coercing positional values.
This small Python harness uses python-jsonschema’s documented interface. Record the dependency version in your environment manifest. The strict pair below is the schema from check three; expand the fixture collection with the optional, nullable, composed, and migrated variants discussed above.
from copy import deepcopy
from jsonschema import Draft202012Validator
schema = {
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "number"}],
"minItems": 2,
"items": False,
}
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
for value, expected in [
(["sensor-a", 12], True),
([], False),
(["sensor-a"], False),
([12, "sensor-a"], False),
(["sensor-a", "12"], False),
(["sensor-a", 12, True], False),
]:
before = deepcopy(value)
assert validator.is_valid(value) is expected
assert value == before
Name fixtures by the rule they exercise, such as missing-measurement or unexpected-quality-tail. A failure then points to a contract boundary rather than an unexplained array difference. Assert structured error locations where useful, but avoid coupling the entire suite to exact English error messages that a library update can legitimately change.
Finally, test one accepted and one rejected record through the deployed application wrapper. Confirm the accepted record reaches business logic unchanged and the rejected record does not execute the operation. That final boundary catches adapters that sort arrays, drop nulls, or reshape data after validation, undoing the positional guarantees the schema suite was written to establish.