Shape matching
REST and GraphQL responses, gRPC replies, consumed messages and sheet rows all use the same matcher from ProtoTest.Json. Only the spelling that reaches it differs.
Objects are partial, arrays are exact and positional, every mismatch is reported at once, and names match case-insensitively while values compare exactly.
response.Should.MatchShape(new
{
id = JsonValue.GreaterThan(0),
status = "pending",
customer = new { email = JsonValue.StringEndingWith("@example.test") },
lines = new[]
{
new { product = "notebook", quantity = 2 },
new { product = "pen", quantity = JsonValue.Between(1, 10) }
}
});
A failure names the subject, then every mismatch with its path:
GET /api/orders/42 - Shape mismatch failed with 1 error(s):
• [$.status]: Values did not match. (Expected: "pending", Actual: "cancelled")
| Subject | Call |
|---|---|
| REST response | response.Should.MatchShape(shape); PostAsync(...).ExpectAsync(shape) asserts in the call |
| GraphQL response | response.Should.MatchShape(shape); ExpectAsync / ExpectNextAsync assert for you |
| gRPC reply | ProtoGrpcAssertions.For(reply).Should.MatchShape(shape) |
| Consumed message | message.Should.MatchShape(shape) |
| Sheets table row | row.Should.MatchShape(shape) |
| Sheets model row | row.ShouldMatchShape(shape); a record is a user type, so C# cannot give it a Should extension property |
The rules
Objects are partial
The matcher checks only the properties you list. It ignores the rest, so new API fields do not break the test.
A listed property that is missing fails with "Property was missing from the JSON response." A non-object where you expected one fails with "Expected an object."
Exact matching
.Should.MatchShape(shape, exact: true), or .ExpectAsync(shape, exact: true), demands the other direction too: every field present in the JSON must be mentioned by the shape. A field the shape does not mention fails with its path:
GET /api/orders/42 - Shape mismatch failed with 1 error(s):
• [$.extra]: Property was not mentioned in the expected shape. (Expected: "<not mentioned>", Actual: "true")
An unmentioned branch reports its shallowest path once, $.extra, not every leaf below it, and the unmentioned fields are collected alongside ordinary mismatches, so one run shows everything. A value constraint mentions its whole subtree, so customer = JsonValue.Any() never fails an exact match on the fields inside customer.
Partial matching stays the default, and the two share one matcher.
Arrays are exact
An array must have the same length and each element is compared by position. A length difference is reported, and the elements that do line up are still compared, so you see every problem at once.
If you only care that a list is not empty, use a constraint instead of an array:
users = JsonValue.NotNull()
Anything enumerable counts as an array (List<T>, LINQ results, arrays), except strings and dictionaries. This makes expected lists easy to compute:
users = createdUsers
.Select(user => new { user.Id, user.Email, user.Role })
.OrderBy(user => user.Id, StringComparer.Ordinal)
.ToArray()
Rules apply at every depth
Nested objects are partial, nested arrays are exact, all the way down.
Names are case-insensitive
workspaceCount matches WorkspaceCount in the JSON. Names come from [JsonPropertyName] if present, otherwise the naming policy in your JsonSerializerOptions, otherwise the C# name. Pass options with PropertyNameCaseInsensitive = false to make matching strict, and [JsonIgnore] properties are not part of the expected shape.
String values are compared exactly and case-sensitively.
Values are compared by type
| Expected | Matches |
|---|---|
| number | any JSON number with the same decimal value; 12 matches 12.0, compared as decimal first, then double for values outside its range |
string | the same string, ordinal and case-sensitive |
bool | true / false |
| enum | its name (case-insensitive) or its numeric value |
Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, Uri | a JSON string that parses to the same value |
null | JSON null |
| dictionary with string keys | an object, like an anonymous type; non-string keys are compared through their invariant string form |
A null expectation requires an actual JSON null. Use JsonValue.NotNull() or JsonValue.Any() when you only need the property to be present.
Every mismatch is reported
The matcher collects all mismatches, then throws one JsonShapeMismatchException:
Shape mismatch failed with 3 error(s):
• [$.lines]: Array lengths did not match. (Expected: '2', Actual: '3')
• [$.status]: Values did not match. (Expected: "pending", Actual: "cancelled")
• [$.customer.email]: Expected ends with "@example.test", but found 'jane@example.org'. (Expected: "ends with "@example.test"", Actual: "jane@example.org")
public sealed class JsonShapeMismatchException : ProtoAssertionException
{
IReadOnlyList<JsonShapeMismatch> Mismatches { get; } // (PropertyPath, Reason, Expected, Actual)
IReadOnlyList<string> MatchedProperties { get; }
}
Each protocol producer rethrows the matcher failure as its own assertion exception (RestAssertionException, GraphQLAssertionException, GrpcAssertionException, MessagingAssertionException, SpreadsheetAssertionException) whose message starts with the subject it was asserted against and keeps the matcher exception, and so the mismatch list, as InnerException:
GET /api/orders/42 - Shape mismatch failed with 1 error(s):
• [$.status]: Values did not match. (Expected: "pending", Actual: "cancelled")
The subject is the REST request identifier, the GraphQL operation, the gRPC message type, the messaging destination, or the sheet row's Sheet!Range. A model row names the record type.
Empty or invalid JSON throws JsonDocumentAssertionException instead, with the content in its Content property. The producer wraps it the same way.
Constraints
JsonValue gives you values that match by rule rather than equality:
| Constraint | Matches |
|---|---|
JsonValue.Any() | anything, including null; the property only has to exist |
JsonValue.NotNull() | anything except null |
JsonValue.Null() | null |
JsonValue.GreaterThan(x) | > x |
JsonValue.GreaterThanOrEqualTo(x) | >= x |
JsonValue.LessThan(x) | < x |
JsonValue.LessThanOrEqualTo(x) | <= x |
JsonValue.Between(min, max) | min <= value <= max |
JsonValue.OneOf(a, b, ...) | equal to one of the values |
JsonValue.StringContaining(s, comparison?) | contains s |
JsonValue.StringStartingWith(s, comparison?) | starts with s |
JsonValue.StringEndingWith(s, comparison?) | ends with s |
JsonValue.Regex(pattern, options?) | matches the regular expression |
JsonValue.StringMatching(predicate, description?) | a string satisfying your predicate |
JsonValue.Matching(predicate, description) | any value satisfying your predicate |
String comparisons default to StringComparison.Ordinal. The comparison constraints and Between take an IComparable threshold. OneOf compares scalars, allowing numeric types to differ (the JSON value is converted to decimal) while everything else must be type-compatible, so the string "2" never matches 2.
Comparison constraints convert the JSON value to the type you passed. A value that cannot be converted does not match: JsonValue.LessThan(10) against "not-a-number" is a mismatch, not an exception. Use 200m rather than 200 when the value is a decimal amount.
Matching receives the raw value: a string, a decimal or double (JSON numbers never surface as a .NET long), a bool, null, or raw JSON text for objects and arrays. StringMatching receives the value as a string, or null when it is not one.
createdAt = JsonValue.StringMatching(
value => DateTimeOffset.TryParse(value, out var at) && at > DateTimeOffset.UtcNow.AddMinutes(-5),
"a timestamp from the last five minutes")
The description is what appears as Expected when it fails, so write it for the reader.
Custom constraints
Implement IJsonValueMatcher for anything reusable:
public interface IJsonValueMatcher
{
string Description { get; }
bool Matches(object? actual, out string? errorMessage);
}
public sealed record IsoCurrency : IJsonValueMatcher
{
public string Description => "an ISO 4217 currency code";
public bool Matches(object? actual, out string? errorMessage)
{
if (actual is string { Length: 3 } code && code.All(char.IsAsciiLetterUpper))
{
errorMessage = null;
return true;
}
errorMessage = $"Expected {Description}, but found '{actual}'.";
return false;
}
}
response.Should.MatchShape(new { total = new { currency = new IsoCurrency() } });
In GraphQL shapes, any IJsonValueMatcher is also treated as a leaf field when building the selection set.
Using the matcher directly
IReadOnlyList<string> matched = JsonShapeMatcher.AssertMatch(json, expectedShape);
IReadOnlyList<string> matched = JsonShapeMatcher.AssertMatch(jsonElement, expectedShape, options);
It returns the JSON paths that matched and throws the matcher exceptions (JsonShapeMismatchException, JsonDocumentAssertionException). The protocol assertions wrap those with the subject they were made against.
Evidence in the trace
The protocol assertions and Sheets model rows run through the shared ProtoShapeAssertion.Assert, which records one assert.json.shape operation per assertion. A failed assertion from this page records:
{
"operation": "assert.json.shape",
"expected.type": "<>f__AnonymousType0...",
"shape.expected": "{ \"status\": \"cancelled\" }",
"shape.actual": "{ \"status\": \"pending\" }",
"shape.result": "mismatched",
"shape.mismatch_count": "1",
"shape.mismatches": "[$.status]: Values did not match.",
"shape.matches": "[\"$.id\"]"
}
On success the same operation carries shape.result: matched with matched.property_count and matched.properties instead of the mismatch fields, and shape.exact: true appears only when the assertion ran in exact mode. The expected-shape description is capped at depth 16 and 4096 expanded containers. Deeper nodes become <Type at depth limit>, so a cyclic or pathologically large shape cannot hang the run. On success the protocol records an observation built from the matched paths, http.contract.shape for REST or graphql.contract.shape for GraphQL, which is what OpenAPI and GraphQL schema coverage consume.
Limits
- Partial objects mean extra server fields never fail a shape, unless the assertion asks for
exact: true, where a field no property mentioned fails. - Arrays are length- and position-sensitive; order matters.
- Numbers surface as
decimalordouble, neverlong; strings are compared ordinally. - In exact mode a value constraint mentions its whole subtree. The fields inside a constrained value are not checked.
Next
- ProtoTrace: where the assertion evidence lands.
- Coverage: matched paths become contract coverage.