Quick answer: Test JSON Schema
containswith arrays containing zero, one, and several matching elements, then test missing fields, duplicate matches, and interactions with other array keywords. These eight checks target Draft 2020-12 and separate “some items match” from “all items are valid” before an API accepts production batches.
Imagine a notification batch that must contain exactly one primary recipient. A schema can look convincing while accepting two primary recipients, treating an empty object as a match, or rejecting an otherwise legitimate secondary recipient. Those failures come from different rules and need separate fixtures.
The JSON Schema introduction explains the broader contract model. This guide focuses on array membership and counting. The examples deliberately declare Draft 2020-12; future specification work and older dialects should not be substituted without reviewing behavior. Wikipedia’s JSON overview provides background on arrays and other JSON values.
1. Does the baseline require one matching element anywhere in the array?
Start with an array type and a contains subschema that recognizes one simple value. Confirm that a matching value succeeds at the beginning, middle, and end. An empty array and an array with no matching value should fail, while unrelated neighboring values remain permitted by this deliberately minimal contract.
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"array","contains":{"const":"primary"}}
Expected results are concrete: [] fails, ["secondary"] fails, and ["secondary","primary"] passes. The same match at index zero must also pass. Testing several positions catches application wrappers that inspect only the first item even though the schema describes a search across the array.
This baseline leaves other elements unrestricted. Consequently, [null,"primary",42] passes. That is useful for understanding the keyword but might be inappropriate for a recipient endpoint. Record the result rather than assuming contains also validates the shape of every neighbor. Later checks add that independent requirement.
Keep type: "array" explicit. Array keywords alone do not reject an object or string simply because it is not an array. A request body with the wrong top-level type should fail on its own terms, before the service attempts batch processing. This fixture prevents a misleading success when the client sends one recipient object instead of a recipient list.
2. Are the minimum and maximum counting matches rather than total items?
Use minContains and maxContains to bound the number of elements matching the adjacent contains subschema. Test just below, exactly at, and just above each boundary. Include nonmatching neighbors so the suite distinguishes the number of matches from the total array length and does not accidentally enforce a batch-size rule instead.
{"type":"array","contains":{"const":"primary"},"minContains":1,"maxContains":1}
One primary recipient and several secondary recipients pass this schema. Two primary values fail, even if the whole array is short. Conversely, one primary value in a long array still meets the match-count rule. A separate maxItems limit belongs beside it when the endpoint also needs a maximum batch size.
Put the three keywords in the same schema object. Moving minContains into a sibling allOf branch does not turn it into a global modifier of contains elsewhere. The validation specification defines these keywords through the adjacent contains result. Test the generated schema after composition, rather than trusting the source template.
For a range such as two through three matches, use fixtures with one, two, three, and four matches. Name the fixtures by the expected rule: “too few primary recipients” communicates more than “bad array.” Clear names help a reviewer distinguish a schema regression from an intentional change to the product requirement.
3. Does a zero-match policy override the default minimum explicitly?
A policy forbidding matches needs minContains zero and maxContains zero together with contains. The default minimum is one, so a zero maximum alone does not express an ordinary exclusion rule for arrays. Test empty input, several nonmatching values, and one forbidden value to confirm the intended zero-match policy.
{"type":"array","contains":{"const":"blocked"},"minContains":0,"maxContains":0}
The empty array passes. An array containing only "allowed" passes. Adding "blocked" makes it fail. This is a count assertion, not a filter: a validator should report the invalid request rather than silently removing the forbidden element and continuing with changed data.
If the endpoint also requires a nonempty batch, add minItems: 1. A zero lower bound for matching items says nothing about whether the overall batch may be empty. Preserve both fixtures so a future edit cannot accidentally conflate the two policies.
There is another useful diagnostic: minContains and maxContains without contains in the same schema object have no validation effect. A schema generator that accidentally drops the matching subschema can therefore weaken the contract. Include a schema review or generated-output snapshot alongside runtime fixtures, and verify the complete deployed artifact rather than only a hand-written example.
4. Can a missing discriminator accidentally satisfy the matching schema?
An object membership test should require its discriminator and constrain the element type. Properties alone does not require the named field, and object-specific keywords do not reject unrelated JSON types by themselves. Test an empty object, a missing field, a null element, and both matching and nonmatching discriminator values before trusting match counts.
{"type":"array","contains":{"type":"object","required":["role"],"properties":{"role":{"const":"primary"}}}}
An array containing {"role":"primary"} passes. [{}], [null], and [{"role":"secondary"}] fail. Removing required would allow the empty object to satisfy the property rule. Removing the object type would make other JSON types a potential source of unexpected matches because neither required nor properties applies to them as an object.
Inspect the payload with the JSON Viewer when a nested field appears present but a fixture behaves unexpectedly. The viewer helps examine structure; it does not run the schema or prove the count. A discriminator nested under recipient.role is different from one directly under role.
Require the field at the exact level where it is read. If matching depends on a nested object, validate and require that object too. Avoid relying on defaults to create a missing role: default is not a portable instruction to mutate input during schema validation. A client should send the identity your contract requires.
5. Are all elements checked independently of whether one element matches?
Combine items with contains when every batch member needs a common shape but only some members need a special role. A matching primary record must not make a malformed secondary record acceptable. Test one good primary beside a wrong-type neighbor, a missing required field, and a valid secondary record to separate these assertions.
{
"type":"array",
"items":{"type":"object","required":["role"],"properties":{"role":{"enum":["primary","secondary"]}}},
"contains":{"required":["role"],"properties":{"role":{"const":"primary"}}}
}
Here items rejects null neighbors and objects missing role. Contains supplies the additional requirement that at least one element names the primary role. The matching subschema relies on the common item constraint for object type in this particular example; keeping a self-contained matching schema is often clearer when extracting it into a reusable definition.
The official array guide explains the difference between list validation and membership. In a real recipient contract, items could also require an address and an identifier. Test those fields on secondary recipients as carefully as on the primary recipient.
A practical regression fixture starts with a valid two-record batch, then removes one required field from the secondary record. The result must become invalid. That mutation catches integrations that stop checking once they find a primary recipient. A passing contains condition is only one part of a successful complete schema evaluation.
6. Do duplicate matches count as separate positions?
Contains counts matching positions rather than distinct business entities or distinct complete values. Two identical matching records contribute two matches. Test that case with an exact-one contract, then decide separately whether duplicates should be forbidden throughout the array and whether repeated identifiers require additional application checks beyond the schema’s value comparisons.
For the exact-one string schema, ["primary","primary"] fails the maximum. With an unrestricted default contains rule, the same array passes because at least one matching position exists. Neither result implies deduplication. Keep the original array unchanged so the caller can correct the actual submitted batch.
The morning guide on JSON Schema uniqueItems tests covers complete-value equality. Add that keyword when repeated complete values are invalid, but do not describe it as “unique by customer ID.” Two records with the same ID and different properties can still be distinct JSON values.
Consider a batch with one primary record and a second record for the same person marked secondary. Exact-one-primary validation can pass. If the business forbids sending twice to the same person, application validation needs a separate identity policy. Case folding, whitespace handling, and alias recognition are product decisions that schema match counts cannot infer.
7. Are matching positions treated correctly by unevaluatedItems?
Under Draft 2020-12, positions matching contains are considered evaluated for unevaluatedItems. Test a matching string alongside an unmatched number and an unmatched boolean, with a rule permitting only numeric unevaluated items. This verifies the interaction directly instead of assuming that a successful membership test marks the entire array as evaluated.
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"array","contains":{"type":"string"},"unevaluatedItems":{"type":"number"}}
The fixture ["primary",12] passes. ["primary",true] fails because the boolean is unmatched and must satisfy the numeric rule. [12] fails because no string satisfies contains. These three outcomes separate successful membership from validation of remaining positions.
The Draft 2020-12 release notes describe this interaction explicitly. Treat migration from another dialect as a behavior change requiring tests, especially when tuple rules or composed branches also participate. Do not simply change the schema URI and assume old fixtures retain their meaning.
Also inspect whether an adjacent items schema already evaluates all positions. If so, unevaluatedItems is not a second universal pass over those same elements. Use the object closure tests for the related distinction between local restrictions and evaluation-aware closure, while keeping object and array fixtures separate.
8. Does the deployed validator reproduce the fixture matrix without changing input?
Run the fixtures with an explicitly selected Draft 2020-12 validator and validate each schema before evaluating data. Preserve the original input, expected validity, and failure context. Repeat the matrix against the validator used by your actual API, because a passing demonstration in another language does not verify deployment configuration or integration behavior.
This executable Python example uses jsonschema’s documented validator interface. Install the package in your development environment and record its version with your dependency lock. It tests one representative boundary from each check; retain the additional cases described above in the API suite.
from copy import deepcopy
from jsonschema import Draft202012Validator
def check(schema, value, expected):
Draft202012Validator.check_schema(schema)
original = deepcopy(value)
assert Draft202012Validator(schema).is_valid(value) is expected
assert value == original
base = {"type": "array", "contains": {"const": "primary"}}
check(base, ["secondary", "primary"], True)
exact = dict(base, minContains=1, maxContains=1)
check(exact, ["primary", "primary"], False)
zero = dict(base, minContains=0, maxContains=0)
check(zero, [], True)
member = {"type": "object", "required": ["role"],
"properties": {"role": {"const": "primary"}}}
check({"type": "array", "contains": member}, [{}], False)
check(dict(base, items={"type": "string"}), ["primary", None], False)
check(base, ["primary", "primary"], True)
remaining = {"type": "array", "contains": {"type": "string"},
"unevaluatedItems": {"type": "number"}}
check(remaining, ["primary", True], False)
check(remaining, ["primary", 12], True)
Save expected results under version control and run them after schema generation, dependency updates, and deployment packaging changes. Assert keyword paths when useful, but avoid tying every test to exact English error messages. The release gate should demonstrate the contract’s behavior, including its rejected boundaries, rather than merely prove that the validator can load a schema file.